Compare commits

..

10 Commits

Author SHA1 Message Date
Adam Hopkins
0b750593da Version Bump 2022-07-31 13:03:51 +03:00
Adam Hopkins
5b1686ceba Use path.parts instead of match (#2508) 2022-07-31 12:58:21 +03:00
Adam Hopkins
86baaef1ec Use pathlib for path resolution (#2506) 2022-07-31 12:58:18 +03:00
Adam Hopkins
2b4b78da88 Fix dotted test 2022-07-31 12:56:57 +03:00
Néstor Pérez
ee6d8cfe11 Prevent directory traversion with static files (#2495)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
Co-authored-by: Zhiwei Liang <zhi.wei.liang@outlook.com>
2022-07-31 12:56:52 +03:00
Adam Hopkins
c4da66bf1f Update changelog 2022-01-06 12:26:35 +02:00
Adam Hopkins
d50d3b8448 Bump version 2022-01-06 12:21:44 +02:00
Adam Hopkins
313f97ac77 Only display MOTD in ASGI on startup (#2349) 2022-01-06 11:22:57 +02:00
Adam Hopkins
a23547d73b Ignore name argument on Python 3.7 (#2355)
Co-authored-by: Néstor Pérez <25409753+prryplatypus@users.noreply.github.com>
Co-authored-by: Ryu juheon <saidbysolo@gmail.com>
2022-01-06 10:57:24 +02:00
Adam Hopkins
34d1dee407 Add config.update support for setters (#2354) 2022-01-06 09:55:03 +02:00
127 changed files with 2311 additions and 6028 deletions

2
.black.toml Normal file
View File

@@ -0,0 +1,2 @@
[tool.black]
line-length = 79

28
.codeclimate.yml Normal file
View File

@@ -0,0 +1,28 @@
exclude_patterns:
- "sanic/__main__.py"
- "sanic/application/logo.py"
- "sanic/application/motd.py"
- "sanic/reloader_helpers.py"
- "sanic/simple.py"
- "sanic/utils.py"
- ".github/"
- "changelogs/"
- "docker/"
- "docs/"
- "examples/"
- "scripts/"
- "tests/"
checks:
argument-count:
enabled: false
file-lines:
config:
threshold: 1000
method-count:
config:
threshold: 40
complex-logic:
enabled: false
method-complexity:
config:
threshold: 10

View File

@@ -3,12 +3,13 @@ branch = True
source = sanic
omit =
site-packages
sanic/application/logo.py
sanic/application/motd.py
sanic/cli
sanic/__main__.py
sanic/compat.py
sanic/reloader_helpers.py
sanic/simple.py
sanic/utils.py
sanic/cli
[html]
directory = coverage
@@ -20,13 +21,3 @@ exclude_lines =
noqa
NOQA
pragma: no cover
TYPE_CHECKING
omit =
site-packages
sanic/__main__.py
sanic/compat.py
sanic/reloader_helpers.py
sanic/simple.py
sanic/utils.py
sanic/cli
skip_empty = True

View File

@@ -7,11 +7,10 @@ on:
tags:
- "!*" # Do not execute on tags
pull_request:
branches:
- main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
test:
if: github.event.pull_request.draft == false
runs-on: ${{ matrix.os }}
strategy:
matrix:
@@ -21,6 +20,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
@@ -29,10 +29,9 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install tox
- name: Run coverage
run: tox -e coverage
continue-on-error: true
- uses: codecov/codecov-action@v2
- uses: paambaati/codeclimate-action@v2.5.3
if: always()
env:
CC_TEST_REPORTER_ID: ${{ secrets.CODECLIMATE }}
with:
files: ./coverage.xml
fail_ci_if_error: false
coverageCommand: tox -e coverage

View File

@@ -313,10 +313,8 @@ Version 21.3.0
`#2074 <https://github.com/sanic-org/sanic/pull/2074>`_
Performance adjustments in ``handle_request_``
Version 20.12.3 🔷
------------------
`Current LTS version`
Version 20.12.3
---------------
**Bugfixes**
@@ -350,8 +348,8 @@ Version 19.12.5
`#2027 <https://github.com/sanic-org/sanic/pull/2027>`_
Remove old chardet requirement, add in hard multidict requirement
Version 20.12.0 🔹
-----------------
Version 20.12.0
---------------
**Features**
@@ -359,6 +357,11 @@ Version 20.12.0 🔹
`#1993 <https://github.com/sanic-org/sanic/pull/1993>`_
Add disable app registry
Version 20.12.0
---------------
**Features**
*
`#1945 <https://github.com/sanic-org/sanic/pull/1945>`_
Static route more verbose if file not found

View File

@@ -140,7 +140,6 @@ To maintain the code consistency, Sanic uses following tools.
#. `isort <https://github.com/timothycrosley/isort>`_
#. `black <https://github.com/python/black>`_
#. `flake8 <https://github.com/PyCQA/flake8>`_
#. `slotscheck <https://github.com/ariebovenberg/slotscheck>`_
isort
*****
@@ -168,13 +167,7 @@ flake8
#. pycodestyle
#. Ned Batchelder's McCabe script
slotscheck
**********
``slotscheck`` ensures that there are no problems with ``__slots__``
(e.g. overlaps, or missing slots in base classes).
``isort``\ , ``black``\ , ``flake8`` and ``slotscheck`` checks are performed during ``tox`` lint checks.
``isort``\ , ``black`` and ``flake8`` checks are performed during ``tox`` lint checks.
The **easiest** way to make your code conform is to run the following before committing.

View File

@@ -66,15 +66,15 @@ ifdef include_tests
isort -rc sanic tests
else
$(info Sorting Imports)
isort -rc sanic tests
isort -rc sanic tests --profile=black
endif
endif
black:
black sanic tests
black --config ./.black.toml sanic tests
isort:
isort sanic tests
isort sanic tests --profile=black
pretty: black isort

View File

@@ -66,7 +66,7 @@ Sanic | Build fast. Run fast.
Sanic is a **Python 3.7+** 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://sanicframework.org/en/guide/deployment/running.html#asgi>`_.
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/sanic-org/sanic/>`_ | `Help and discussion board <https://community.sanicframework.org/>`_ | `User Guide <https://sanicframework.org>`_ | `Chat on Discord <https://discord.gg/FARQzAEMAA>`_
@@ -114,7 +114,7 @@ Hello World Example
from sanic import Sanic
from sanic.response import json
app = Sanic("my-hello-world-app")
app = Sanic("My Hello, world app")
@app.route('/')
async def test(request):

View File

@@ -24,11 +24,7 @@ import sanic
# -- General configuration ------------------------------------------------
extensions = [
"sphinx.ext.autodoc",
"m2r2",
"enum_tools.autoenum",
]
extensions = ["sphinx.ext.autodoc", "m2r2"]
templates_path = ["_templates"]

View File

@@ -9,7 +9,7 @@ API
===
.. toctree::
:maxdepth: 3
:maxdepth: 2
👥 User Guide <https://sanicframework.org/guide/>
sanic/api_reference

View File

@@ -15,19 +15,3 @@ sanic.config
.. automodule:: sanic.config
:members:
:show-inheritance:
sanic.application.constants
---------------------------
.. automodule:: sanic.application.constants
:exclude-members: StrEnum
:members:
:show-inheritance:
:inherited-members:
sanic.application.state
-----------------------
.. automodule:: sanic.application.state
:members:
:show-inheritance:

View File

@@ -17,14 +17,6 @@ sanic.handlers
:show-inheritance:
sanic.headers
--------------
.. automodule:: sanic.headers
:members:
:show-inheritance:
sanic.request
-------------

View File

@@ -16,3 +16,10 @@ sanic.server
:members:
:show-inheritance:
sanic.worker
------------
.. automodule:: sanic.worker
:members:
:show-inheritance:

View File

@@ -1,8 +1,6 @@
📜 Changelog
============
.. mdinclude:: ./releases/22/22.6.md
.. mdinclude:: ./releases/22/22.3.md
.. mdinclude:: ./releases/21/21.12.md
.. mdinclude:: ./releases/21/21.9.md
.. include:: ../../CHANGELOG.rst

View File

@@ -1,12 +1,10 @@
## Version 21.12.1 🔷
_Current LTS version_
## Version 21.12.1
- [#2349](https://github.com/sanic-org/sanic/pull/2349) Only display MOTD on startup
- [#2354](https://github.com/sanic-org/sanic/pull/2354) Ignore name argument in Python 3.7
- [#2355](https://github.com/sanic-org/sanic/pull/2355) Add config.update support for all config values
## Version 21.12.0 🔹
## Version 21.12.0
### Features
- [#2260](https://github.com/sanic-org/sanic/pull/2260) Allow early Blueprint registrations to still apply later added objects

View File

@@ -1,52 +0,0 @@
## Version 22.3.0
### Features
- [#2347](https://github.com/sanic-org/sanic/pull/2347) API for multi-application server
- 🚨 *BREAKING CHANGE*: The old `sanic.worker.GunicornWorker` has been **removed**. To run Sanic with `gunicorn`, you should use it thru `uvicorn` [as described in their docs](https://www.uvicorn.org/#running-with-gunicorn).
- 🧁 *SIDE EFFECT*: Named background tasks are now supported, even in Python 3.7
- [#2357](https://github.com/sanic-org/sanic/pull/2357) Parse `Authorization` header as `Request.credentials`
- [#2361](https://github.com/sanic-org/sanic/pull/2361) Add config option to skip `Touchup` step in application startup
- [#2372](https://github.com/sanic-org/sanic/pull/2372) Updates to CLI help messaging
- [#2382](https://github.com/sanic-org/sanic/pull/2382) Downgrade warnings to backwater debug messages
- [#2396](https://github.com/sanic-org/sanic/pull/2396) Allow for `multidict` v0.6
- [#2401](https://github.com/sanic-org/sanic/pull/2401) Upgrade CLI catching for alternative application run types
- [#2402](https://github.com/sanic-org/sanic/pull/2402) Conditionally inject CLI arguments into factory
- [#2413](https://github.com/sanic-org/sanic/pull/2413) Add new start and stop event listeners to reloader process
- [#2414](https://github.com/sanic-org/sanic/pull/2414) Remove loop as required listener arg
- [#2415](https://github.com/sanic-org/sanic/pull/2415) Better exception for bad URL parsing
- [sanic-routing#47](https://github.com/sanic-org/sanic-routing/pull/47) Add a new extention parameter type: `<file:ext>`, `<file:ext=jpg>`, `<file:ext=jpg|png|gif|svg>`, `<file=int:ext>`, `<file=int:ext=jpg|png|gif|svg>`, `<file=float:ext=tar.gz>`
- 👶 *BETA FEATURE*: This feature will not work with `path` type matching, and is being released as a beta feature only.
- [sanic-routing#57](https://github.com/sanic-org/sanic-routing/pull/57) Change `register_pattern` to accept a `str` or `Pattern`
- [sanic-routing#58](https://github.com/sanic-org/sanic-routing/pull/58) Default matching on non-empty strings only, and new `strorempty` pattern type
- 🚨 *BREAKING CHANGE*: Previously a route with a dynamic string parameter (`/<foo>` or `/<foo:str>`) would match on any string, including empty strings. It will now **only** match a non-empty string. To retain the old behavior, you should use the new parameter type: `/<foo:strorempty>`.
### Bugfixes
- [#2373](https://github.com/sanic-org/sanic/pull/2373) Remove `error_logger` on websockets
- [#2381](https://github.com/sanic-org/sanic/pull/2381) Fix newly assigned `None` in task registry
- [sanic-routing#52](https://github.com/sanic-org/sanic-routing/pull/52) Add type casting to regex route matching
- [sanic-routing#60](https://github.com/sanic-org/sanic-routing/pull/60) Add requirements check on regex routes (this resolves, for example, multiple static directories with differing `host` values)
### Deprecations and Removals
- [#2362](https://github.com/sanic-org/sanic/pull/2362) 22.3 Deprecations and changes
1. `debug=True` and `--debug` do _NOT_ automatically run `auto_reload`
2. Default error render is with plain text (browsers still get HTML by default because `auto` looks at headers)
3. `config` is required for `ErrorHandler.finalize`
4. `ErrorHandler.lookup` requires two positional args
5. Unused websocket protocol args removed
- [#2344](https://github.com/sanic-org/sanic/pull/2344) Deprecate loading of lowercase environment variables
### Developer infrastructure
- [#2363](https://github.com/sanic-org/sanic/pull/2363) Revert code coverage back to Codecov
- [#2405](https://github.com/sanic-org/sanic/pull/2405) Upgrade tests for `sanic-routing` changes
- [sanic-testing#35](https://github.com/sanic-org/sanic-testing/pull/35) Allow for httpx v0.22
### Improved Documentation
- [#2350](https://github.com/sanic-org/sanic/pull/2350) Fix link in README for ASGI
- [#2398](https://github.com/sanic-org/sanic/pull/2398) Document middleware on_request and on_response
- [#2409](https://github.com/sanic-org/sanic/pull/2409) Add missing documentation for `Request.respond`
### Miscellaneous
- [#2376](https://github.com/sanic-org/sanic/pull/2376) Fix typing for `ListenerMixin.listener`
- [#2383](https://github.com/sanic-org/sanic/pull/2383) Clear deprecation warning in `asyncio.wait`
- [#2387](https://github.com/sanic-org/sanic/pull/2387) Cleanup `__slots__` implementations
- [#2390](https://github.com/sanic-org/sanic/pull/2390) Clear deprecation warning in `asyncio.get_event_loop`

View File

@@ -1,43 +0,0 @@
## Version 22.6.0 🔶
_Current version_
### Features
- [#2378](https://github.com/sanic-org/sanic/pull/2378) Introduce HTTP/3 and autogeneration of TLS certificates in `DEBUG` mode
- 👶 *EARLY RELEASE FEATURE*: Serving Sanic over HTTP/3 is an early release feature. It does not yet fully cover the HTTP/3 spec, but instead aims for feature parity with Sanic's existing HTTP/1.1 server. Websockets, WebTransport, push responses are examples of some features not yet implemented.
- 📦 *EXTRA REQUIREMENT*: Not all HTTP clients are capable of interfacing with HTTP/3 servers. You may need to install a [HTTP/3 capable client](https://curl.se/docs/http3.html).
- 📦 *EXTRA REQUIREMENT*: In order to use TLS autogeneration, you must install either [mkcert](https://github.com/FiloSottile/mkcert) or [trustme](https://github.com/python-trio/trustme).
- [#2416](https://github.com/sanic-org/sanic/pull/2416) Add message to `task.cancel`
- [#2420](https://github.com/sanic-org/sanic/pull/2420) Add exception aliases for more consistent naming with standard HTTP response types (`BadRequest`, `MethodNotAllowed`, `RangeNotSatisfiable`)
- [#2432](https://github.com/sanic-org/sanic/pull/2432) Expose ASGI `scope` as a property on the `Request` object
- [#2438](https://github.com/sanic-org/sanic/pull/2438) Easier access to websocket class for annotation: `from sanic import Websocket`
- [#2439](https://github.com/sanic-org/sanic/pull/2439) New API for reading form values with options: `Request.get_form`
- [#2445](https://github.com/sanic-org/sanic/pull/2445) Add custom `loads` function
- [#2447](https://github.com/sanic-org/sanic/pull/2447), [#2486](https://github.com/sanic-org/sanic/pull/2486) Improved API to support setting cache control headers
- [#2453](https://github.com/sanic-org/sanic/pull/2453) Move verbosity filtering to logger
- [#2475](https://github.com/sanic-org/sanic/pull/2475) Expose getter for current request using `Request.get_current()`
### Bugfixes
- [#2448](https://github.com/sanic-org/sanic/pull/2448) Fix to allow running with `pythonw.exe` or places where there is no `sys.stdout`
- [#2451](https://github.com/sanic-org/sanic/pull/2451) Trigger `http.lifecycle.request` signal in ASGI mode
- [#2455](https://github.com/sanic-org/sanic/pull/2455) Resolve typing of stacked route definitions
- [#2463](https://github.com/sanic-org/sanic/pull/2463) Properly catch websocket CancelledError in websocket handler in Python 3.7
### Deprecations and Removals
- [#2487](https://github.com/sanic-org/sanic/pull/2487) v22.6 deprecations and changes
1. Optional application registry
1. Execution of custom handlers after some part of response was sent
1. Configuring fallback handlers on the `ErrorHandler`
1. Custom `LOGO` setting
1. `sanic.response.stream`
1. `AsyncioServer.init`
### Developer infrastructure
- [#2449](https://github.com/sanic-org/sanic/pull/2449) Clean up `black` and `isort` config
- [#2479](https://github.com/sanic-org/sanic/pull/2479) Fix some flappy tests
### Improved Documentation
- [#2461](https://github.com/sanic-org/sanic/pull/2461) Update example to match current application naming standards
- [#2466](https://github.com/sanic-org/sanic/pull/2466) Better type annotation for `Extend`
- [#2485](https://github.com/sanic-org/sanic/pull/2485) Improved help messages in CLI

View File

@@ -4,7 +4,6 @@ from sanic import Sanic, response
app = Sanic("DelayedResponseApp", strict_slashes=True)
app.config.AUTO_EXTEND = False
@app.get("/")
@@ -12,7 +11,7 @@ async def handler(request):
return response.redirect("/sleep/3")
@app.get("/sleep/<t:float>")
@app.get("/sleep/<t:number>")
async def handler2(request, t=0.3):
await sleep(t)
return response.text(f"Slept {t:.1f} seconds.\n")

View File

@@ -1,26 +1,3 @@
[build-system]
requires = ["setuptools<60.0", "wheel"]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.black]
line-length = 79
[tool.isort]
atomic = true
default_section = "THIRDPARTY"
include_trailing_comma = true
known_first_party = "sanic"
known_third_party = "pytest"
line_length = 79
lines_after_imports = 2
lines_between_types = 1
multi_line_output = 3
profile = "black"
[[tool.mypy.overrides]]
module = [
"httptools.*",
"trustme.*",
"sanic_routing.*",
]
ignore_missing_imports = true

View File

@@ -6,4 +6,4 @@ python:
path: .
extra_requirements:
- docs
system_packages: true
system_packages: true

View File

@@ -4,7 +4,6 @@ from sanic.blueprints import Blueprint
from sanic.constants import HTTPMethod
from sanic.request import Request
from sanic.response import HTTPResponse, html, json, text
from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket
__all__ = (
@@ -14,7 +13,6 @@ __all__ = (
"HTTPMethod",
"HTTPResponse",
"Request",
"Websocket",
"html",
"json",
"text",

View File

@@ -1 +1 @@
__version__ = "22.6.2"
__version__ = "21.12.2"

View File

@@ -3,23 +3,28 @@ from __future__ import annotations
import asyncio
import logging
import logging.config
import os
import platform
import re
import sys
from asyncio import (
AbstractEventLoop,
CancelledError,
Protocol,
Task,
ensure_future,
get_running_loop,
get_event_loop,
wait_for,
)
from asyncio.futures import Future
from collections import defaultdict, deque
from contextlib import suppress
from functools import partial
from importlib import import_module
from inspect import isawaitable
from pathlib import Path
from socket import socket
from ssl import SSLContext
from traceback import format_exc
from types import SimpleNamespace
from typing import (
@@ -43,11 +48,17 @@ from typing import (
from urllib.parse import urlencode, urlunparse
from warnings import filterwarnings
from sanic_routing.exceptions import FinalizationError, NotFound
from sanic_routing.route import Route
from sanic_routing.exceptions import ( # type: ignore
FinalizationError,
NotFound,
)
from sanic_routing.route import Route # type: ignore
from sanic import reloader_helpers
from sanic.application.ext import setup_ext
from sanic.application.state import ApplicationState, Mode, ServerStage
from sanic.application.logo import get_logo
from sanic.application.motd import MOTD
from sanic.application.state import ApplicationState, Mode
from sanic.asgi import ASGIApp
from sanic.base.root import BaseSanic
from sanic.blueprint_group import BlueprintGroup
@@ -55,7 +66,7 @@ from sanic.blueprints import Blueprint
from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support
from sanic.config import SANIC_PREFIX, Config
from sanic.exceptions import (
BadRequest,
InvalidUsage,
SanicException,
ServerError,
URLBuildError,
@@ -65,12 +76,12 @@ from sanic.helpers import _default
from sanic.http import Stage
from sanic.log import (
LOGGING_CONFIG_DEFAULTS,
Colors,
deprecation,
error_logger,
logger,
)
from sanic.mixins.listeners import ListenerEvent
from sanic.mixins.runner import RunnerMixin
from sanic.models.futures import (
FutureException,
FutureListener,
@@ -85,26 +96,33 @@ from sanic.models.handler_types import Sanic as SanicVar
from sanic.request import Request
from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream
from sanic.router import Router
from sanic.server import AsyncioServer, HttpProtocol
from sanic.server import Signal as ServerSignal
from sanic.server import serve, serve_multiple, serve_single, try_use_uvloop
from sanic.server.protocols.websocket_protocol import WebSocketProtocol
from sanic.server.websockets.impl import ConnectionClosed
from sanic.signals import Signal, SignalRouter
from sanic.tls import process_to_context
from sanic.touchup import TouchUp, TouchUpMeta
if TYPE_CHECKING:
if TYPE_CHECKING: # no cov
try:
from sanic_ext import Extend # type: ignore
from sanic_ext.extensions.base import Extension # type: ignore
except ImportError:
Extend = TypeVar("Extend", Type) # type: ignore
Extend = TypeVar("Extend") # type: ignore
if OS_IS_WINDOWS: # no cov
if OS_IS_WINDOWS:
enable_windows_color_support()
filterwarnings("once", category=DeprecationWarning)
SANIC_PACKAGES = ("sanic-routing", "sanic-testing", "sanic-ext")
class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
class Sanic(BaseSanic, metaclass=TouchUpMeta):
"""
The main application instance
"""
@@ -139,6 +157,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
"error_handler",
"go_fast",
"listeners",
"name",
"named_request_middleware",
"named_response_middleware",
"request_class",
@@ -169,8 +188,8 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
configure_logging: bool = True,
register: Optional[bool] = None,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
) -> None:
super().__init__(name=name)
@@ -202,6 +221,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
self.blueprints: Dict[str, Blueprint] = {}
self.configure_logging: bool = configure_logging
self.ctx: Any = ctx or SimpleNamespace()
self.debug = False
self.error_handler: ErrorHandler = error_handler or ErrorHandler()
self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list)
self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {}
@@ -218,14 +238,23 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
# Register alternative method names
self.go_fast = self.run
if register is not None:
deprecation(
"The register argument is deprecated and will stop working "
"in v22.6. After v22.6 all apps will be added to the Sanic "
"app registry.",
22.6,
)
self.config.REGISTER = register
if self.config.REGISTER:
self.__class__.register_app(self)
self.router.ctx.app = self
self.signal_router.ctx.app = self
self.__class__.register_app(self)
if dumps:
BaseHTTPResponse._dumps = dumps # type: ignore
if loads:
Request._loads = loads # type: ignore
@property
def loop(self):
@@ -236,18 +265,12 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
Only supported when using the `app.run` method.
"""
if self.state.stage is ServerStage.STOPPED and self.asgi is False:
if not self.is_running and self.asgi is False:
raise SanicException(
"Loop can only be retrieved after the app has started "
"running. Not supported with `create_server` function"
)
try:
return get_running_loop()
except RuntimeError:
if sys.version_info > (3, 10):
return asyncio.get_event_loop_policy().get_event_loop()
else:
return asyncio.get_event_loop()
return get_event_loop()
# -------------------------------------------------------------------- #
# Registration
@@ -270,7 +293,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
valid = ", ".join(
map(lambda x: x.lower(), ListenerEvent.__members__.keys())
)
raise BadRequest(f"Invalid event: {event}. Use one of: {valid}")
raise InvalidUsage(f"Invalid event: {event}. Use one of: {valid}")
if "." in _event:
self.signal(_event.value)(
@@ -727,24 +750,37 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
"has at least partially been sent."
)
# ----------------- deprecated -----------------
handler = self.error_handler._lookup(
exception, request.name if request else None
)
if handler:
logger.warning(
deprecation(
"An error occurred while handling the request after at "
"least some part of the response was sent to the client. "
"The response from your custom exception handler "
f"{handler.__name__} will not be sent to the client."
"Exception handlers should only be used to generate the "
"exception responses. If you would like to perform any "
"other action on a raised exception, consider using a "
"Therefore, the response from your custom exception "
f"handler {handler.__name__} will not be sent to the "
"client. Beginning in v22.6, Sanic will stop executing "
"custom exception handlers in this scenario. Exception "
"handlers should only be used to generate the exception "
"responses. If you would like to perform any other "
"action on a raised exception, please consider using a "
"signal handler like "
'`@app.signal("http.lifecycle.exception")`\n'
"For further information, please see the docs: "
"https://sanicframework.org/en/guide/advanced/"
"signals.html",
22.6,
)
try:
response = self.error_handler.response(request, exception)
if isawaitable(response):
response = await response
except BaseException as e:
logger.error("An error occurred in the exception handler.")
error_logger.exception(e)
# ----------------------------------------------
return
# -------------------------------------------- #
@@ -925,7 +961,6 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
"response": response,
},
)
...
await response.send(end_stream=True)
elif isinstance(response, ResponseStream):
resp = await response(request)
@@ -969,10 +1004,10 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
cancelled = False
try:
await fut
except (CancelledError, ConnectionClosed):
cancelled = True
except Exception as e:
self.error_handler.log(request, e)
except (CancelledError, ConnectionClosed):
cancelled = True
finally:
self.websocket_tasks.remove(fut)
if cancelled:
@@ -1017,6 +1052,283 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
# Execution
# -------------------------------------------------------------------- #
def make_coffee(self, *args, **kwargs):
self.state.coffee = True
self.run(*args, **kwargs)
def run(
self,
host: Optional[str] = None,
port: Optional[int] = None,
*,
debug: bool = False,
auto_reload: Optional[bool] = None,
ssl: Union[None, SSLContext, dict, str, list, tuple] = None,
sock: Optional[socket] = None,
workers: int = 1,
protocol: Optional[Type[Protocol]] = None,
backlog: int = 100,
register_sys_signals: bool = True,
access_log: Optional[bool] = None,
unix: Optional[str] = None,
loop: AbstractEventLoop = None,
reload_dir: Optional[Union[List[str], str]] = None,
noisy_exceptions: Optional[bool] = None,
motd: bool = True,
fast: bool = False,
verbosity: int = 0,
motd_display: Optional[Dict[str, str]] = None,
) -> None:
"""
Run the HTTP Server and listen until keyboard interrupt or term
signal. On termination, drain connections before closing.
:param host: Address to host on
:type host: str
:param port: Port to host on
:type port: int
:param debug: Enables debug output (slows server)
:type debug: bool
:param auto_reload: Reload app whenever its source code is changed.
Enabled by default in debug mode.
:type auto_relaod: bool
:param ssl: SSLContext, or location of certificate and key
for SSL encryption of worker(s)
:type ssl: str, dict, SSLContext or list
:param sock: Socket for the server to accept connections from
:type sock: socket
:param workers: Number of processes received before it is respected
:type workers: int
:param protocol: Subclass of asyncio Protocol class
:type protocol: type[Protocol]
:param backlog: a number of unaccepted connections that the system
will allow before refusing new connections
:type backlog: int
:param register_sys_signals: Register SIG* events
:type register_sys_signals: bool
:param access_log: Enables writing access logs (slows server)
:type access_log: bool
:param unix: Unix socket to listen on instead of TCP port
:type unix: str
:param noisy_exceptions: Log exceptions that are normally considered
to be quiet/silent
:type noisy_exceptions: bool
:return: Nothing
"""
self.state.verbosity = verbosity
if fast and workers != 1:
raise RuntimeError("You cannot use both fast=True and workers=X")
if motd_display:
self.config.MOTD_DISPLAY.update(motd_display)
if reload_dir:
if isinstance(reload_dir, str):
reload_dir = [reload_dir]
for directory in reload_dir:
direc = Path(directory)
if not direc.is_dir():
logger.warning(
f"Directory {directory} could not be located"
)
self.state.reload_dirs.add(Path(directory))
if loop is not None:
raise TypeError(
"loop is not a valid argument. To use an existing loop, "
"change to create_server().\nSee more: "
"https://sanic.readthedocs.io/en/latest/sanic/deploying.html"
"#asynchronous-support"
)
if auto_reload or auto_reload is None and debug:
auto_reload = True
if os.environ.get("SANIC_SERVER_RUNNING") != "true":
return reloader_helpers.watchdog(1.0, self)
if sock is None:
host, port = host or "127.0.0.1", port or 8000
if protocol is None:
protocol = (
WebSocketProtocol if self.websocket_enabled else HttpProtocol
)
# Set explicitly passed configuration values
for attribute, value in {
"ACCESS_LOG": access_log,
"AUTO_RELOAD": auto_reload,
"MOTD": motd,
"NOISY_EXCEPTIONS": noisy_exceptions,
}.items():
if value is not None:
setattr(self.config, attribute, value)
if fast:
self.state.fast = True
try:
workers = len(os.sched_getaffinity(0))
except AttributeError:
workers = os.cpu_count() or 1
server_settings = self._helper(
host=host,
port=port,
debug=debug,
ssl=ssl,
sock=sock,
unix=unix,
workers=workers,
protocol=protocol,
backlog=backlog,
register_sys_signals=register_sys_signals,
)
if self.config.USE_UVLOOP is True or (
self.config.USE_UVLOOP is _default and not OS_IS_WINDOWS
):
try_use_uvloop()
try:
self.is_running = True
self.is_stopping = False
if workers > 1 and os.name != "posix":
logger.warn(
f"Multiprocessing is currently not supported on {os.name},"
" using workers=1 instead"
)
workers = 1
if workers == 1:
serve_single(server_settings)
else:
serve_multiple(server_settings, workers)
except BaseException:
error_logger.exception(
"Experienced exception while trying to serve"
)
raise
finally:
self.is_running = False
logger.info("Server Stopped")
def stop(self):
"""
This kills the Sanic
"""
if not self.is_stopping:
self.shutdown_tasks(timeout=0)
self.is_stopping = True
get_event_loop().stop()
async def create_server(
self,
host: Optional[str] = None,
port: Optional[int] = None,
*,
debug: bool = False,
ssl: Union[None, SSLContext, dict, str, list, tuple] = None,
sock: Optional[socket] = None,
protocol: Type[Protocol] = None,
backlog: int = 100,
access_log: Optional[bool] = None,
unix: Optional[str] = None,
return_asyncio_server: bool = False,
asyncio_server_kwargs: Dict[str, Any] = None,
noisy_exceptions: Optional[bool] = None,
) -> Optional[AsyncioServer]:
"""
Asynchronous version of :func:`run`.
This method will take care of the operations necessary to invoke
the *before_start* events via :func:`trigger_events` method invocation
before starting the *sanic* app in Async mode.
.. note::
This does not support multiprocessing and is not the preferred
way to run a :class:`Sanic` application.
:param host: Address to host on
:type host: str
:param port: Port to host on
:type port: int
:param debug: Enables debug output (slows server)
:type debug: bool
:param ssl: SSLContext, or location of certificate and key
for SSL encryption of worker(s)
:type ssl: SSLContext or dict
:param sock: Socket for the server to accept connections from
:type sock: socket
:param protocol: Subclass of asyncio Protocol class
:type protocol: type[Protocol]
:param backlog: a number of unaccepted connections that the system
will allow before refusing new connections
:type backlog: int
:param access_log: Enables writing access logs (slows server)
:type access_log: bool
:param return_asyncio_server: flag that defines whether there's a need
to return asyncio.Server or
start it serving right away
:type return_asyncio_server: bool
:param asyncio_server_kwargs: key-value arguments for
asyncio/uvloop create_server method
:type asyncio_server_kwargs: dict
:param noisy_exceptions: Log exceptions that are normally considered
to be quiet/silent
:type noisy_exceptions: bool
:return: AsyncioServer if return_asyncio_server is true, else Nothing
"""
if sock is None:
host, port = host or "127.0.0.1", port or 8000
if protocol is None:
protocol = (
WebSocketProtocol if self.websocket_enabled else HttpProtocol
)
# Set explicitly passed configuration values
for attribute, value in {
"ACCESS_LOG": access_log,
"NOISY_EXCEPTIONS": noisy_exceptions,
}.items():
if value is not None:
setattr(self.config, attribute, value)
server_settings = self._helper(
host=host,
port=port,
debug=debug,
ssl=ssl,
sock=sock,
unix=unix,
loop=get_event_loop(),
protocol=protocol,
backlog=backlog,
run_async=return_asyncio_server,
)
if self.config.USE_UVLOOP is not _default:
error_logger.warning(
"You are trying to change the uvloop configuration, but "
"this is only effective when using the run(...) method. "
"When using the create_server(...) method Sanic will use "
"the already existing loop."
)
main_start = server_settings.pop("main_start", None)
main_stop = server_settings.pop("main_stop", None)
if main_start or main_stop:
logger.warning(
"Listener events for the main process are not available "
"with create_server()"
)
return await serve(
asyncio_server_kwargs=asyncio_server_kwargs, **server_settings
)
async def _run_request_middleware(
self, request, request_name=None
): # no cov
@@ -1100,6 +1412,100 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
break
return response
def _helper(
self,
host: Optional[str] = None,
port: Optional[int] = None,
debug: bool = False,
ssl: Union[None, SSLContext, dict, str, list, tuple] = None,
sock: Optional[socket] = None,
unix: Optional[str] = None,
workers: int = 1,
loop: AbstractEventLoop = None,
protocol: Type[Protocol] = HttpProtocol,
backlog: int = 100,
register_sys_signals: bool = True,
run_async: bool = False,
):
"""Helper function used by `run` and `create_server`."""
if self.config.PROXIES_COUNT and self.config.PROXIES_COUNT < 0:
raise ValueError(
"PROXIES_COUNT cannot be negative. "
"https://sanic.readthedocs.io/en/latest/sanic/config.html"
"#proxy-configuration"
)
ssl = process_to_context(ssl)
self.debug = debug
self.state.host = host
self.state.port = port
self.state.workers = workers
self.state.ssl = ssl
self.state.unix = unix
self.state.sock = sock
server_settings = {
"protocol": protocol,
"host": host,
"port": port,
"sock": sock,
"unix": unix,
"ssl": ssl,
"app": self,
"signal": ServerSignal(),
"loop": loop,
"register_sys_signals": register_sys_signals,
"backlog": backlog,
}
self.motd(self.serve_location)
if sys.stdout.isatty() and not self.state.is_debug:
error_logger.warning(
f"{Colors.YELLOW}Sanic is running in PRODUCTION mode. "
"Consider using '--debug' or '--dev' while actively "
f"developing your application.{Colors.END}"
)
# Register start/stop events
for event_name, settings_name, reverse in (
("main_process_start", "main_start", False),
("main_process_stop", "main_stop", True),
):
listeners = self.listeners[event_name].copy()
if reverse:
listeners.reverse()
# Prepend sanic to the arguments when listeners are triggered
listeners = [partial(listener, self) for listener in listeners]
server_settings[settings_name] = listeners # type: ignore
if run_async:
server_settings["run_async"] = True
return server_settings
@property
def serve_location(self) -> str:
serve_location = ""
proto = "http"
if self.state.ssl is not None:
proto = "https"
if self.state.unix:
serve_location = f"{self.state.unix} {proto}://..."
elif self.state.sock:
serve_location = f"{self.state.sock.getsockname()} {proto}://..."
elif self.state.host and self.state.port:
# colon(:) is legal for a host only in an ipv6 address
display_host = (
f"[{self.state.host}]"
if ":" in self.state.host
else self.state.host
)
serve_location = f"{proto}://{display_host}:{self.state.port}"
return serve_location
def _build_endpoint_name(self, *parts):
parts = [self.name, *parts]
return ".".join(parts)
@@ -1113,10 +1519,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
async def _listener(
app: Sanic, loop: AbstractEventLoop, listener: ListenerType
):
try:
maybe_coro = listener(app) # type: ignore
except TypeError:
maybe_coro = listener(app, loop) # type: ignore
maybe_coro = listener(app, loop)
if maybe_coro and isawaitable(maybe_coro):
await maybe_coro
@@ -1151,14 +1554,13 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
) -> Task:
if not isinstance(task, Future):
prepped = cls._prep_task(task, app, loop)
if sys.version_info < (3, 8): # no cov
task = loop.create_task(prepped)
if sys.version_info < (3, 8):
if name:
error_logger.warning(
"Cannot set a name for a task when using Python 3.7. "
"Your task will be created without a name."
)
task.get_name = lambda: name
task = loop.create_task(prepped)
else:
task = loop.create_task(prepped, name=name)
@@ -1196,6 +1598,12 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
:param task: future, couroutine or awaitable
"""
if name and sys.version_info == (3, 7):
name = None
error_logger.warning(
"Cannot set a name for a task when using Python 3.7. Your "
"task will be created without a name."
)
try:
loop = self.loop # Will raise SanicError if loop is not started
return self._loop_add_task(
@@ -1218,6 +1626,12 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
def get_task(
self, name: str, *, raise_exception: bool = True
) -> Optional[Task]:
if sys.version_info < (3, 8):
error_logger.warning(
"This feature (get_task) is only supported on using "
"Python 3.8+."
)
return
try:
return self._task_registry[name]
except KeyError:
@@ -1234,13 +1648,19 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
*,
raise_exception: bool = True,
) -> None:
if sys.version_info < (3, 8):
error_logger.warning(
"This feature (cancel_task) is only supported on using "
"Python 3.8+."
)
return
task = self.get_task(name, raise_exception=raise_exception)
if task and not task.cancelled():
args: Tuple[str, ...] = ()
if msg:
if sys.version_info >= (3, 9):
args = (msg,)
else: # no cov
else:
raise RuntimeError(
"Cancelling a task with a message is only supported "
"on Python 3.9+."
@@ -1252,9 +1672,16 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
...
def purge_tasks(self):
for key, task in self._task_registry.items():
if sys.version_info < (3, 8):
error_logger.warning(
"This feature (purge_tasks) is only supported on using "
"Python 3.8+."
)
return
for task in self.tasks:
if task.done() or task.cancelled():
self._task_registry[key] = None
name = task.get_name()
self._task_registry[name] = None
self._task_registry = {
k: v for k, v in self._task_registry.items() if v is not None
@@ -1263,22 +1690,31 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
def shutdown_tasks(
self, timeout: Optional[float] = None, increment: float = 0.1
):
if sys.version_info < (3, 8):
error_logger.warning(
"This feature (shutdown_tasks) is only supported on using "
"Python 3.8+."
)
return
for task in self.tasks:
if task.get_name() != "RunServer":
task.cancel()
task.cancel()
if timeout is None:
timeout = self.config.GRACEFUL_SHUTDOWN_TIMEOUT
while len(self._task_registry) and timeout:
with suppress(RuntimeError):
running_loop = get_running_loop()
running_loop.run_until_complete(asyncio.sleep(increment))
self.loop.run_until_complete(asyncio.sleep(increment))
self.purge_tasks()
timeout -= increment
@property
def tasks(self):
if sys.version_info < (3, 8):
error_logger.warning(
"This feature (tasks) is only supported on using "
"Python 3.8+."
)
return
return iter(self._task_registry.values())
# -------------------------------------------------------------------- #
@@ -1315,7 +1751,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
self.config.update_config(config)
@property
def asgi(self) -> bool:
def asgi(self):
return self.state.asgi
@asgi.setter
@@ -1328,13 +1764,6 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
@debug.setter
def debug(self, value: bool):
deprecation(
"Setting the value of a Sanic application's debug value directly "
"is deprecated and will be removed in v22.9. Please set it using "
"the CLI, app.run, app.prepare, or directly set "
"app.state.mode to Mode.DEBUG.",
22.9,
)
mode = Mode.DEBUG if value else Mode.PRODUCTION
self.state.mode = mode
@@ -1347,68 +1776,85 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
self.config.AUTO_RELOAD = value
@property
def state(self) -> ApplicationState: # type: ignore
"""
:return: The application state
"""
def state(self):
return self._state
@property
def is_running(self):
deprecation(
"Use of the is_running property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
return self.state.is_running
@is_running.setter
def is_running(self, value: bool):
deprecation(
"Use of the is_running property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
self.state.is_running = value
@property
def is_stopping(self):
deprecation(
"Use of the is_stopping property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
return self.state.is_stopping
@is_stopping.setter
def is_stopping(self, value: bool):
deprecation(
"Use of the is_stopping property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
self.state.is_stopping = value
@property
def reload_dirs(self):
return self.state.reload_dirs
def motd(self, serve_location):
if self.config.MOTD:
mode = [f"{self.state.mode},"]
if self.state.fast:
mode.append("goin' fast")
if self.state.asgi:
mode.append("ASGI")
else:
if self.state.workers == 1:
mode.append("single worker")
else:
mode.append(f"w/ {self.state.workers} workers")
display = {
"mode": " ".join(mode),
"server": self.state.server,
"python": platform.python_version(),
"platform": platform.platform(),
}
extra = {}
if self.config.AUTO_RELOAD:
reload_display = "enabled"
if self.state.reload_dirs:
reload_display += ", ".join(
[
"",
*(
str(path.absolute())
for path in self.state.reload_dirs
),
]
)
display["auto-reload"] = reload_display
packages = []
for package_name in SANIC_PACKAGES:
module_name = package_name.replace("-", "_")
try:
module = import_module(module_name)
packages.append(f"{package_name}=={module.__version__}")
except ImportError:
...
if packages:
display["packages"] = ", ".join(packages)
if self.config.MOTD_DISPLAY:
extra.update(self.config.MOTD_DISPLAY)
logo = (
get_logo(coffee=self.state.coffee)
if self.config.LOGO == "" or self.config.LOGO is True
else self.config.LOGO
)
MOTD.output(logo, serve_location, display, extra)
@property
def ext(self) -> Extend:
if not hasattr(self, "_ext"):
@@ -1496,8 +1942,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
if not Sanic.test_mode:
raise e
def signalize(self, allow_fail_builtin=True):
self.signal_router.allow_fail_builtin = allow_fail_builtin
def signalize(self):
try:
self.signal_router.finalize()
except FinalizationError as e:
@@ -1507,18 +1952,14 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
async def _startup(self):
self._future_registry.clear()
# Startup Sanic Extensions
if not hasattr(self, "_ext"):
setup_ext(self)
if hasattr(self, "_ext"):
self.ext._display()
if self.state.is_debug and self.config.TOUCHUP is not True:
self.config.TOUCHUP = False
elif self.config.TOUCHUP is _default:
self.config.TOUCHUP = True
# Setup routers
self.signalize(self.config.TOUCHUP)
self.signalize()
self.finalize()
# TODO: Replace in v22.6 to check against apps in app registry
@@ -1534,11 +1975,8 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
self.__class__._uvloop_setting = self.config.USE_UVLOOP
# Startup time optimizations
if self.state.primary:
# TODO:
# - Raise warning if secondary apps have error handler config
if self.config.TOUCHUP:
TouchUp.run(self)
ErrorHandler.finalize(self.error_handler, config=self.config)
TouchUp.run(self)
self.state.is_started = True
@@ -1554,9 +1992,8 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
"shutdown",
):
raise SanicException(f"Invalid server event: {event}")
logger.debug(
f"Triggering server events: {event}", extra={"verbosity": 1}
)
if self.state.verbosity >= 1:
logger.debug(f"Triggering server events: {event}")
reverse = concern == "shutdown"
if loop is None:
loop = self.loop

View File

@@ -1,23 +0,0 @@
from enum import Enum, IntEnum, auto
class StrEnum(str, Enum):
def _generate_next_value_(name: str, *args) -> str: # type: ignore
return name.lower()
class Server(StrEnum):
SANIC = auto()
ASGI = auto()
GUNICORN = auto()
class Mode(StrEnum):
PRODUCTION = auto()
DEBUG = auto()
class ServerStage(IntEnum):
STOPPED = auto()
PARTIAL = auto()
SERVING = auto()

View File

@@ -5,7 +5,7 @@ from importlib import import_module
from typing import TYPE_CHECKING
if TYPE_CHECKING:
if TYPE_CHECKING: # no cov
from sanic import Sanic
try:

View File

@@ -3,8 +3,6 @@ import sys
from os import environ
from sanic.compat import is_atty
BASE_LOGO = """
@@ -46,7 +44,7 @@ ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def get_logo(full=False, coffee=False):
logo = (
(FULL_COLOR_LOGO if full else (COFFEE_LOGO if coffee else COLOR_LOGO))
if is_atty()
if sys.stdout.isatty()
else BASE_LOGO
)

View File

@@ -1,10 +1,11 @@
import sys
from abc import ABC, abstractmethod
from shutil import get_terminal_size
from textwrap import indent, wrap
from typing import Dict, Optional
from sanic import __version__
from sanic.compat import is_atty
from sanic.log import logger
@@ -35,7 +36,7 @@ class MOTD(ABC):
data: Dict[str, str],
extra: Dict[str, str],
) -> None:
motd_class = MOTDTTY if is_atty() else MOTDBasic
motd_class = MOTDTTY if sys.stdout.isatty() else MOTDBasic
motd_class(logo, serve_location, data, extra).display()

View File

@@ -1,86 +0,0 @@
import os
import sys
import time
from contextlib import contextmanager
from queue import Queue
from threading import Thread
if os.name == "nt": # noqa
import ctypes # noqa
class _CursorInfo(ctypes.Structure):
_fields_ = [("size", ctypes.c_int), ("visible", ctypes.c_byte)]
class Spinner: # noqa
def __init__(self, message: str) -> None:
self.message = message
self.queue: Queue[int] = Queue()
self.spinner = self.cursor()
self.thread = Thread(target=self.run)
def start(self):
self.queue.put(1)
self.thread.start()
self.hide()
def run(self):
while self.queue.get():
output = f"\r{self.message} [{next(self.spinner)}]"
sys.stdout.write(output)
sys.stdout.flush()
time.sleep(0.1)
self.queue.put(1)
def stop(self):
self.queue.put(0)
self.thread.join()
self.show()
@staticmethod
def cursor():
while True:
for cursor in "|/-\\":
yield cursor
@staticmethod
def hide():
if os.name == "nt":
ci = _CursorInfo()
handle = ctypes.windll.kernel32.GetStdHandle(-11)
ctypes.windll.kernel32.GetConsoleCursorInfo(
handle, ctypes.byref(ci)
)
ci.visible = False
ctypes.windll.kernel32.SetConsoleCursorInfo(
handle, ctypes.byref(ci)
)
elif os.name == "posix":
sys.stdout.write("\033[?25l")
sys.stdout.flush()
@staticmethod
def show():
if os.name == "nt":
ci = _CursorInfo()
handle = ctypes.windll.kernel32.GetStdHandle(-11)
ctypes.windll.kernel32.GetConsoleCursorInfo(
handle, ctypes.byref(ci)
)
ci.visible = True
ctypes.windll.kernel32.SetConsoleCursorInfo(
handle, ctypes.byref(ci)
)
elif os.name == "posix":
sys.stdout.write("\033[?25h")
sys.stdout.flush()
@contextmanager
def loading(message: str = "Loading"): # noqa
spinner = Spinner(message)
spinner.start()
yield
spinner.stop()

View File

@@ -3,25 +3,33 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from socket import socket
from ssl import SSLContext
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union
from typing import TYPE_CHECKING, Any, Optional, Set, Union
from sanic.application.constants import Mode, Server, ServerStage
from sanic.log import VerbosityFilter, logger
from sanic.server.async_server import AsyncioServer
from sanic.log import logger
if TYPE_CHECKING:
from sanic import Sanic
@dataclass
class ApplicationServerInfo:
settings: Dict[str, Any]
stage: ServerStage = field(default=ServerStage.STOPPED)
server: Optional[AsyncioServer] = field(default=None)
class StrEnum(str, Enum):
def _generate_next_value_(name: str, *args) -> str: # type: ignore
return name.lower()
class Server(StrEnum):
SANIC = auto()
ASGI = auto()
GUNICORN = auto()
class Mode(StrEnum):
PRODUCTION = auto()
DEBUG = auto()
@dataclass
@@ -37,15 +45,12 @@ class ApplicationState:
unix: Optional[str] = field(default=None)
mode: Mode = field(default=Mode.PRODUCTION)
reload_dirs: Set[Path] = field(default_factory=set)
auto_reload: bool = field(default=False)
server: Server = field(default=Server.SANIC)
is_running: bool = field(default=False)
is_started: bool = field(default=False)
is_stopping: bool = field(default=False)
verbosity: int = field(default=0)
workers: int = field(default=0)
primary: bool = field(default=True)
server_info: List[ApplicationServerInfo] = field(default_factory=list)
# This property relates to the ApplicationState instance and should
# not be changed except in the __post_init__ method
@@ -69,23 +74,6 @@ class ApplicationState:
if getattr(self.app, "configure_logging", False) and self.app.debug:
logger.setLevel(logging.DEBUG)
def set_verbosity(self, value: int):
VerbosityFilter.verbosity = value
@property
def is_debug(self):
return self.mode is Mode.DEBUG
@property
def stage(self) -> ServerStage:
if not self.server_info:
return ServerStage.STOPPED
if all(info.stage is ServerStage.SERVING for info in self.server_info):
return ServerStage.SERVING
elif any(
info.stage is ServerStage.SERVING for info in self.server_info
):
return ServerStage.PARTIAL
return ServerStage.STOPPED

View File

@@ -1,15 +1,14 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Optional
from typing import Optional
from urllib.parse import quote
import sanic.app # noqa
from sanic.compat import Header
from sanic.exceptions import ServerError
from sanic.helpers import _default
from sanic.http import Stage
from sanic.log import logger
from sanic.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport
from sanic.request import Request
from sanic.response import BaseHTTPResponse
@@ -17,35 +16,29 @@ from sanic.server import ConnInfo
from sanic.server.websockets.connection import WebSocketConnection
if TYPE_CHECKING:
from sanic import Sanic
class Lifespan:
def __init__(self, asgi_app: ASGIApp) -> None:
def __init__(self, asgi_app: "ASGIApp") -> None:
self.asgi_app = asgi_app
if (
"server.init.before"
in self.asgi_app.sanic_app.signal_router.name_index
):
logger.debug(
warnings.warn(
'You have set a listener for "before_server_start" '
"in ASGI mode. "
"It will be executed as early as possible, but not before "
"the ASGI server is started.",
extra={"verbosity": 1},
"the ASGI server is started."
)
if (
"server.shutdown.after"
in self.asgi_app.sanic_app.signal_router.name_index
):
logger.debug(
warnings.warn(
'You have set a listener for "after_server_stop" '
"in ASGI mode. "
"It will be executed as late as possible, but not after "
"the ASGI server is stopped.",
extra={"verbosity": 1},
"the ASGI server is stopped."
)
async def startup(self) -> None:
@@ -95,7 +88,7 @@ class Lifespan:
class ASGIApp:
sanic_app: Sanic
sanic_app: "sanic.app.Sanic"
request: Request
transport: MockTransport
lifespan: Lifespan
@@ -164,13 +157,6 @@ class ASGIApp:
instance.request_body = True
instance.request.conn_info = ConnInfo(instance.transport)
await sanic_app.dispatch(
"http.lifecycle.request",
inline=True,
context={"request": instance.request},
fail_not_found=False,
)
return instance
async def read(self) -> Optional[bytes]:

View File

@@ -5,7 +5,7 @@ from functools import partial
from typing import TYPE_CHECKING, List, Optional, Union
if TYPE_CHECKING:
if TYPE_CHECKING: # no cov
from sanic.blueprints import Blueprint

View File

@@ -21,8 +21,8 @@ from typing import (
Union,
)
from sanic_routing.exceptions import NotFound
from sanic_routing.route import Route
from sanic_routing.exceptions import NotFound # type: ignore
from sanic_routing.route import Route # type: ignore
from sanic.base.root import BaseSanic
from sanic.blueprint_group import BlueprintGroup
@@ -36,7 +36,7 @@ from sanic.models.handler_types import (
)
if TYPE_CHECKING:
if TYPE_CHECKING: # no cov
from sanic import Sanic

View File

@@ -58,41 +58,34 @@ Or, a path to a directory to run as a simple HTTP server:
os.environ.get("SANIC_RELOADER_PROCESS", "") != "true"
)
self.args: List[Any] = []
self.groups: List[Group] = []
def attach(self):
for group in Group._registry:
instance = group.create(self.parser)
instance.attach()
self.groups.append(instance)
group.create(self.parser).attach()
def run(self):
# This is to provide backwards compat -v to display version
legacy_version = len(sys.argv) == 2 and sys.argv[-1] == "-v"
parse_args = ["--version"] if legacy_version else None
if not parse_args:
parsed, unknown = self.parser.parse_known_args()
if unknown and parsed.factory:
for arg in unknown:
if arg.startswith("--"):
self.parser.add_argument(arg.split("=")[0])
self.args = self.parser.parse_args(args=parse_args)
self._precheck()
try:
app = self._get_app()
kwargs = self._build_run_kwargs()
app.run(**kwargs)
except ValueError:
error_logger.exception("Failed to run app")
else:
for http_version in self.args.http:
app.prepare(**kwargs, version=http_version)
Sanic.serve()
def _precheck(self):
if self.args.debug and self.main_process:
error_logger.warning(
"Starting in v22.3, --debug will no "
"longer automatically run the auto-reloader.\n Switch to "
"--dev to continue using that functionality."
)
# # Custom TLS mismatch handling for better diagnostics
if self.main_process and (
# one of cert/key missing
@@ -127,14 +120,6 @@ Or, a path to a directory to run as a simple HTTP server:
delimiter = ":" if ":" in self.args.module else "."
module_name, app_name = self.args.module.rsplit(delimiter, 1)
if module_name == "" and os.path.isdir(self.args.module):
raise ValueError(
"App not found.\n"
" Please use --simple if you are passing a "
"directory to sanic.\n"
f" eg. sanic {self.args.module} --simple"
)
if app_name.endswith("()"):
self.args.factory = True
app_name = app_name[:-2]
@@ -142,26 +127,14 @@ Or, a path to a directory to run as a simple HTTP server:
module = import_module(module_name)
app = getattr(module, app_name, None)
if self.args.factory:
try:
app = app(self.args)
except TypeError:
app = app()
app = app()
app_type_name = type(app).__name__
if not isinstance(app, Sanic):
if callable(app):
solution = f"sanic {self.args.module} --factory"
raise ValueError(
"Module is not a Sanic app, it is a "
f"{app_type_name}\n"
" If this callable returns a "
f"Sanic instance try: \n{solution}"
)
raise ValueError(
f"Module is not a Sanic app, it is a {app_type_name}\n"
f" Perhaps you meant {self.args.module}:app?"
f" Perhaps you meant {self.args.module}.app?"
)
except ImportError as e:
if module_name.startswith(e.name):
@@ -170,14 +143,11 @@ Or, a path to a directory to run as a simple HTTP server:
" Example File: project/sanic_server.py -> app\n"
" Example Module: project.sanic_server.app"
)
sys.exit(1)
else:
raise e
return app
def _build_run_kwargs(self):
for group in self.groups:
group.prepare(self.args)
ssl: Union[None, dict, str, list] = []
if self.args.tlshost:
ssl.append(None)
@@ -202,15 +172,18 @@ Or, a path to a directory to run as a simple HTTP server:
"unix": self.args.unix,
"verbosity": self.args.verbosity or 0,
"workers": self.args.workers,
"auto_tls": self.args.auto_tls,
}
for maybe_arg in ("auto_reload", "dev"):
if getattr(self.args, maybe_arg, False):
kwargs[maybe_arg] = True
if self.args.auto_reload:
kwargs["auto_reload"] = True
if self.args.path:
kwargs["auto_reload"] = True
kwargs["reload_dir"] = self.args.path
if self.args.auto_reload or self.args.debug:
kwargs["reload_dir"] = self.args.path
else:
error_logger.warning(
"Ignoring '--reload-dir' since auto reloading was not "
"enabled. If you would like to watch directories for "
"changes, consider using --debug or --auto-reload."
)
return kwargs

View File

@@ -3,10 +3,9 @@ from __future__ import annotations
from argparse import ArgumentParser, _ArgumentGroup
from typing import List, Optional, Type, Union
from sanic_routing import __version__ as __routing_version__
from sanic_routing import __version__ as __routing_version__ # type: ignore
from sanic import __version__
from sanic.http.constants import HTTP
class Group:
@@ -39,9 +38,6 @@ class Group:
"--no-" + args[0][2:], *args[1:], action="store_false", **kwargs
)
def prepare(self, args) -> None:
...
class GeneralGroup(Group):
name = None
@@ -87,44 +83,6 @@ class ApplicationGroup(Group):
)
class HTTPVersionGroup(Group):
name = "HTTP version"
def attach(self):
http_values = [http.value for http in HTTP.__members__.values()]
self.container.add_argument(
"--http",
dest="http",
action="append",
choices=http_values,
type=int,
help=(
"Which HTTP version to use: HTTP/1.1 or HTTP/3. Value should\n"
"be either 1, or 3. [default 1]"
),
)
self.container.add_argument(
"-1",
dest="http",
action="append_const",
const=1,
help=("Run Sanic server using HTTP/1.1"),
)
self.container.add_argument(
"-3",
dest="http",
action="append_const",
const=3,
help=("Run Sanic server using HTTP/3"),
)
def prepare(self, args):
if not args.http:
args.http = [1]
args.http = tuple(sorted(set(map(HTTP, args.http)), reverse=True))
class SocketGroup(Group):
name = "Socket binding"
@@ -134,6 +92,7 @@ class SocketGroup(Group):
"--host",
dest="host",
type=str,
default="127.0.0.1",
help="Host address [default 127.0.0.1]",
)
self.container.add_argument(
@@ -141,6 +100,7 @@ class SocketGroup(Group):
"--port",
dest="port",
type=int,
default=8000,
help="Port to serve on [default 8000]",
)
self.container.add_argument(
@@ -222,6 +182,18 @@ class DevelopmentGroup(Group):
action="store_true",
help="Run the server in debug mode",
)
self.container.add_argument(
"-d",
"--dev",
dest="debug",
action="store_true",
help=(
"Currently is an alias for --debug. But starting in v22.3, \n"
"--debug will no longer automatically trigger auto_restart. \n"
"However, --dev will continue, effectively making it the \n"
"same as debug + auto_reload."
),
)
self.container.add_argument(
"-r",
"--reload",
@@ -240,22 +212,6 @@ class DevelopmentGroup(Group):
action="append",
help="Extra directories to watch and reload on changes",
)
self.container.add_argument(
"-d",
"--dev",
dest="dev",
action="store_true",
help=("debug + auto reload"),
)
self.container.add_argument(
"--auto-tls",
dest="auto_tls",
action="store_true",
help=(
"Create a temporary TLS certificate for local development "
"(requires mkcert or trustme)"
),
)
class OutputGroup(Group):

View File

@@ -1,9 +1,8 @@
import asyncio
import os
import signal
import sys
from typing import Awaitable
from sys import argv
from multidict import CIMultiDict # type: ignore
@@ -48,12 +47,12 @@ class Header(CIMultiDict):
return self.getall(key, default=[])
use_trio = sys.argv[0].endswith("hypercorn") and "trio" in sys.argv
use_trio = argv[0].endswith("hypercorn") and "trio" in argv
if use_trio: # pragma: no cover
import trio # type: ignore
def stat_async(path) -> Awaitable[os.stat_result]:
def stat_async(path):
return trio.Path(path).stat()
open_async = trio.open_file
@@ -73,7 +72,7 @@ def ctrlc_workaround_for_windows(app):
"""Asyncio wakeups to allow receiving SIGINT in Python"""
while not die:
# If someone else stopped the app, just exit
if app.state.is_stopping:
if app.is_stopping:
return
# Windows Python blocks signal handlers while the event loop is
# waiting for I/O. Frequent wakeups keep interrupts flowing.
@@ -90,7 +89,3 @@ def ctrlc_workaround_for_windows(app):
die = False
signal.signal(signal.SIGINT, ctrlc_handler)
app.add_task(stay_active)
def is_atty() -> bool:
return bool(sys.stdout and sys.stdout.isatty())

View File

@@ -5,7 +5,6 @@ from os import environ
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Sequence, Union
from sanic.constants import LocalCertCreator
from sanic.errorpages import DEFAULT_FORMAT, check_error_format
from sanic.helpers import Default, _default
from sanic.http import Http
@@ -27,25 +26,20 @@ DEFAULT_CONFIG = {
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
"KEEP_ALIVE": True,
"LOCAL_CERT_CREATOR": LocalCertCreator.AUTO,
"LOCAL_TLS_KEY": _default,
"LOCAL_TLS_CERT": _default,
"LOCALHOST": "localhost",
"MOTD": True,
"MOTD_DISPLAY": {},
"NOISY_EXCEPTIONS": False,
"PROXIES_COUNT": None,
"REAL_IP_HEADER": None,
"REGISTER": True,
"REQUEST_BUFFER_SIZE": 65536, # 64 KiB
"REQUEST_MAX_HEADER_SIZE": 8192, # 8 KiB, but cannot exceed 16384
"REQUEST_ID_HEADER": "X-Request-ID",
"REQUEST_MAX_SIZE": 100000000, # 100 megabytes
"REQUEST_TIMEOUT": 60, # 60 seconds
"RESPONSE_TIMEOUT": 60, # 60 seconds
"TLS_CERT_PASSWORD": "",
"TOUCHUP": _default,
"USE_UVLOOP": _default,
"WEBSOCKET_MAX_SIZE": 2**20, # 1 megabyte
"WEBSOCKET_MAX_SIZE": 2 ** 20, # 1 megabyte
"WEBSOCKET_PING_INTERVAL": 20,
"WEBSOCKET_PING_TIMEOUT": 20,
}
@@ -74,15 +68,12 @@ class Config(dict, metaclass=DescriptorMeta):
GRACEFUL_SHUTDOWN_TIMEOUT: float
KEEP_ALIVE_TIMEOUT: int
KEEP_ALIVE: bool
LOCAL_CERT_CREATOR: Union[str, LocalCertCreator]
LOCAL_TLS_KEY: Union[Path, str, Default]
LOCAL_TLS_CERT: Union[Path, str, Default]
LOCALHOST: str
NOISY_EXCEPTIONS: bool
MOTD: bool
MOTD_DISPLAY: Dict[str, str]
NOISY_EXCEPTIONS: bool
PROXIES_COUNT: Optional[int]
REAL_IP_HEADER: Optional[str]
REGISTER: bool
REQUEST_BUFFER_SIZE: int
REQUEST_MAX_HEADER_SIZE: int
REQUEST_ID_HEADER: str
@@ -90,8 +81,6 @@ class Config(dict, metaclass=DescriptorMeta):
REQUEST_TIMEOUT: int
RESPONSE_TIMEOUT: int
SERVER_NAME: str
TLS_CERT_PASSWORD: str
TOUCHUP: Union[Default, bool]
USE_UVLOOP: Union[Default, bool]
WEBSOCKET_MAX_SIZE: int
WEBSOCKET_PING_INTERVAL: int
@@ -109,6 +98,7 @@ class Config(dict, metaclass=DescriptorMeta):
super().__init__({**DEFAULT_CONFIG, **defaults})
self._converters = [str, str_to_bool, float, int]
self._LOGO = ""
if converters:
for converter in converters:
@@ -165,13 +155,17 @@ class Config(dict, metaclass=DescriptorMeta):
"REQUEST_MAX_SIZE",
):
self._configure_header_size()
elif attr == "LOGO":
self._LOGO = value
deprecation(
"Setting the config.LOGO is deprecated and will no longer "
"be supported starting in v22.6.",
22.6,
)
if attr == "LOCAL_CERT_CREATOR" and not isinstance(
self.LOCAL_CERT_CREATOR, LocalCertCreator
):
self.LOCAL_CERT_CREATOR = LocalCertCreator[
self.LOCAL_CERT_CREATOR.upper()
]
@property
def LOGO(self):
return self._LOGO
@property
def FALLBACK_ERROR_FORMAT(self) -> str:
@@ -232,12 +226,9 @@ class Config(dict, metaclass=DescriptorMeta):
`See user guide re: config
<https://sanicframework.org/guide/deployment/configuration.html>`__
"""
lower_case_var_found = False
for key, value in environ.items():
if not key.startswith(prefix):
continue
if not key.isupper():
lower_case_var_found = True
_, config_key = key.split(prefix, 1)
@@ -247,12 +238,6 @@ class Config(dict, metaclass=DescriptorMeta):
break
except ValueError:
pass
if lower_case_var_found:
deprecation(
"Lowercase environment variables will not be "
"loaded into Sanic config beginning in v22.9.",
22.9,
)
def update_config(self, config: Union[bytes, str, dict, Any]):
"""

View File

@@ -24,16 +24,5 @@ class HTTPMethod(str, Enum):
DELETE = auto()
class LocalCertCreator(str, Enum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
AUTO = auto()
TRUSTME = auto()
MKCERT = auto()
HTTP_METHODS = tuple(HTTPMethod.__members__.values())
DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream"
DEFAULT_LOCAL_TLS_KEY = "key.pem"
DEFAULT_LOCAL_TLS_CERT = "cert.pem"

View File

@@ -19,7 +19,7 @@ import typing as t
from functools import partial
from traceback import extract_tb
from sanic.exceptions import BadRequest, SanicException
from sanic.exceptions import InvalidUsage, SanicException
from sanic.helpers import STATUS_CODES
from sanic.request import Request
from sanic.response import HTTPResponse, html, json, text
@@ -506,7 +506,7 @@ def exception_response(
# $ curl localhost:8000 -d '{"foo": "bar"}'
# And provide them with JSONRenderer
renderer = JSONRenderer if request.json else base
except BadRequest:
except InvalidUsage:
renderer = base
else:
renderer = RENDERERS_BY_CONFIG.get(render_format, renderer)

View File

@@ -42,7 +42,7 @@ class NotFound(SanicException):
quiet = True
class BadRequest(SanicException):
class InvalidUsage(SanicException):
"""
**Status**: 400 Bad Request
"""
@@ -51,14 +51,7 @@ class BadRequest(SanicException):
quiet = True
InvalidUsage = BadRequest
class BadURL(BadRequest):
...
class MethodNotAllowed(SanicException):
class MethodNotSupported(SanicException):
"""
**Status**: 405 Method Not Allowed
"""
@@ -71,9 +64,6 @@ class MethodNotAllowed(SanicException):
self.headers = {"Allow": ", ".join(allowed_methods)}
MethodNotSupported = MethodNotAllowed
class ServerError(SanicException):
"""
**Status**: 500 Internal Server Error
@@ -135,19 +125,19 @@ class PayloadTooLarge(SanicException):
quiet = True
class HeaderNotFound(BadRequest):
class HeaderNotFound(InvalidUsage):
"""
**Status**: 400 Bad Request
"""
class InvalidHeader(BadRequest):
class InvalidHeader(InvalidUsage):
"""
**Status**: 400 Bad Request
"""
class RangeNotSatisfiable(SanicException):
class ContentRangeError(SanicException):
"""
**Status**: 416 Range Not Satisfiable
"""
@@ -160,10 +150,7 @@ class RangeNotSatisfiable(SanicException):
self.headers = {"Content-Range": f"bytes */{content_range.total}"}
ContentRangeError = RangeNotSatisfiable
class ExpectationFailed(SanicException):
class HeaderExpectationFailed(SanicException):
"""
**Status**: 417 Expectation Failed
"""
@@ -172,9 +159,6 @@ class ExpectationFailed(SanicException):
quiet = True
HeaderExpectationFailed = ExpectationFailed
class Forbidden(SanicException):
"""
**Status**: 403 Forbidden
@@ -184,7 +168,7 @@ class Forbidden(SanicException):
quiet = True
class InvalidRangeType(RangeNotSatisfiable):
class InvalidRangeType(ContentRangeError):
"""
**Status**: 416 Range Not Satisfiable
"""

View File

@@ -1,13 +1,22 @@
from __future__ import annotations
from typing import Dict, List, Optional, Tuple, Type
from inspect import signature
from typing import Dict, List, Optional, Tuple, Type, Union
from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
from sanic.config import Config
from sanic.errorpages import (
DEFAULT_FORMAT,
BaseRenderer,
HTMLRenderer,
exception_response,
)
from sanic.exceptions import (
ContentRangeError,
HeaderNotFound,
InvalidRangeType,
RangeNotSatisfiable,
SanicException,
)
from sanic.helpers import Default, _default
from sanic.log import deprecation, error_logger
from sanic.models.handler_types import RouteHandler
from sanic.response import text
@@ -26,27 +35,120 @@ class ErrorHandler:
"""
# Beginning in v22.3, the base renderer will be TextRenderer
def __init__(
self,
base: Type[BaseRenderer] = TextRenderer,
fallback: Union[str, Default] = _default,
base: Type[BaseRenderer] = HTMLRenderer,
):
self.handlers: List[Tuple[Type[BaseException], RouteHandler]] = []
self.cached_handlers: Dict[
Tuple[Type[BaseException], Optional[str]], Optional[RouteHandler]
] = {}
self.debug = False
self._fallback = fallback
self.base = base
@classmethod
def finalize(cls, *args, **kwargs):
if fallback is not _default:
self._warn_fallback_deprecation()
@property
def fallback(self):
# This is for backwards compat and can be removed in v22.6
if self._fallback is _default:
return DEFAULT_FORMAT
return self._fallback
@fallback.setter
def fallback(self, value: str):
self._warn_fallback_deprecation()
if not isinstance(value, str):
raise SanicException(
f"Cannot set error handler fallback to: value={value}"
)
self._fallback = value
@staticmethod
def _warn_fallback_deprecation():
deprecation(
"ErrorHandler.finalize is deprecated and no longer needed. "
"Please remove update your code to remove it. ",
22.12,
"Setting the ErrorHandler fallback value directly is "
"deprecated and no longer supported. This feature will "
"be removed in v22.6. Instead, use "
"app.config.FALLBACK_ERROR_FORMAT.",
22.6,
)
@classmethod
def _get_fallback_value(cls, error_handler: ErrorHandler, config: Config):
if error_handler._fallback is not _default:
if config._FALLBACK_ERROR_FORMAT is _default:
return error_handler.fallback
error_logger.warning(
"Conflicting error fallback values were found in the "
"error handler and in the app.config while handling an "
"exception. Using the value from app.config."
)
return config.FALLBACK_ERROR_FORMAT
@classmethod
def finalize(
cls,
error_handler: ErrorHandler,
fallback: Optional[str] = None,
config: Optional[Config] = None,
):
if fallback:
deprecation(
"Setting the ErrorHandler fallback value via finalize() "
"is deprecated and no longer supported. This feature will "
"be removed in v22.6. Instead, use "
"app.config.FALLBACK_ERROR_FORMAT.",
22.6,
)
if config is None:
deprecation(
"Starting in v22.3, config will be a required argument "
"for ErrorHandler.finalize().",
22.3,
)
if fallback and fallback != DEFAULT_FORMAT:
if error_handler._fallback is not _default:
error_logger.warning(
f"Setting the fallback value to {fallback}. This changes "
"the current non-default value "
f"'{error_handler._fallback}'."
)
error_handler._fallback = fallback
if not isinstance(error_handler, cls):
error_logger.warning(
f"Error handler is non-conforming: {type(error_handler)}"
)
sig = signature(error_handler.lookup)
if len(sig.parameters) == 1:
deprecation(
"You are using a deprecated error handler. The lookup "
"method should accept two positional parameters: "
"(exception, route_name: Optional[str]). "
"Until you upgrade your ErrorHandler.lookup, Blueprint "
"specific exceptions will not work properly. Beginning "
"in v22.3, the legacy style lookup method will not "
"work at all.",
22.3,
)
legacy_lookup = error_handler._legacy_lookup
error_handler._lookup = legacy_lookup # type: ignore
def _full_lookup(self, exception, route_name: Optional[str] = None):
return self.lookup(exception, route_name)
def _legacy_lookup(self, exception, route_name: Optional[str] = None):
return self.lookup(exception)
def add(self, exception, handler, route_names: Optional[List[str]] = None):
"""
Add a new exception handler to an already existing handler object.
@@ -60,6 +162,9 @@ class ErrorHandler:
:return: None
"""
# self.handlers is deprecated and will be removed in version 22.3
self.handlers.append((exception, handler))
if route_names:
for route in route_names:
self.cached_handlers[(exception, route)] = handler
@@ -131,7 +236,7 @@ class ErrorHandler:
except Exception:
try:
url = repr(request.url)
except AttributeError: # no cov
except AttributeError:
url = "unknown"
response_message = (
"Exception raised in exception handler " '"%s" for uri: %s'
@@ -160,7 +265,7 @@ class ErrorHandler:
:return:
"""
self.log(request, exception)
fallback = request.app.config.FALLBACK_ERROR_FORMAT
fallback = ErrorHandler._get_fallback_value(self, request.app.config)
return exception_response(
request,
exception,
@@ -176,7 +281,7 @@ class ErrorHandler:
if quiet is False or noisy is True:
try:
url = repr(request.url)
except AttributeError: # no cov
except AttributeError:
url = "unknown"
error_logger.exception(
@@ -219,18 +324,18 @@ class ContentRangeHandler:
try:
self.start = int(start_b) if start_b else None
except ValueError:
raise RangeNotSatisfiable(
raise ContentRangeError(
"'%s' is invalid for Content Range" % (start_b,), self
)
try:
self.end = int(end_b) if end_b else None
except ValueError:
raise RangeNotSatisfiable(
raise ContentRangeError(
"'%s' is invalid for Content Range" % (end_b,), self
)
if self.end is None:
if self.start is None:
raise RangeNotSatisfiable(
raise ContentRangeError(
"Invalid for Content Range parameters", self
)
else:
@@ -242,7 +347,7 @@ class ContentRangeHandler:
self.start = self.total - self.end
self.end = self.total - 1
if self.start >= self.end:
raise RangeNotSatisfiable(
raise ContentRangeError(
"Invalid for Content Range parameters", self
)
self.size = self.end - self.start + 1

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
from urllib.parse import unquote
from sanic.exceptions import InvalidHeader
@@ -18,7 +18,7 @@ Options = Dict[str, Union[int, str]] # key=value fields in various headers
OptionsIterable = Iterable[Tuple[str, str]] # May contain duplicate keys
_token, _quoted = r"([\w!#$%&'*+\-.^_`|~]+)", r'"([^"]*)"'
_param = re.compile(rf";\s*{_token}=(?:{_token}|{_quoted})", re.ASCII)
_param = re.compile(fr";\s*{_token}=(?:{_token}|{_quoted})", re.ASCII)
_firefox_quote_escape = re.compile(r'\\"(?!; |\s*$)')
_ipv6 = "(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}"
_ipv6_re = re.compile(_ipv6)
@@ -394,17 +394,3 @@ def parse_accept(accept: str) -> AcceptContainer:
return AcceptContainer(
sorted(accept_list, key=_sort_accept_value, reverse=True)
)
def parse_credentials(
header: Optional[str],
prefixes: Union[List, Tuple, Set] = None,
) -> Tuple[Optional[str], Optional[str]]:
"""Parses any header with the aim to retrieve any credentials from it."""
if not prefixes or not isinstance(prefixes, (list, tuple, set)):
prefixes = ("Basic", "Bearer", "Token")
if header is not None:
for prefix in prefixes:
if prefix in header:
return prefix, header.partition(prefix)[-1].strip()
return None, header

View File

@@ -3,52 +3,71 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
if TYPE_CHECKING: # no cov
from sanic.request import Request
from sanic.response import BaseHTTPResponse
from asyncio import CancelledError, sleep
from enum import Enum
from sanic.compat import Header
from sanic.exceptions import (
BadRequest,
ExpectationFailed,
HeaderExpectationFailed,
InvalidUsage,
PayloadTooLarge,
ServerError,
ServiceUnavailable,
)
from sanic.headers import format_http1_response
from sanic.helpers import has_message_body
from sanic.http.constants import Stage
from sanic.http.stream import Stream
from sanic.log import access_logger, error_logger, logger
from sanic.touchup import TouchUpMeta
class Stage(Enum):
"""
Enum for representing the stage of the request/response cycle
| ``IDLE`` Waiting for request
| ``REQUEST`` Request headers being received
| ``HANDLER`` Headers done, handler running
| ``RESPONSE`` Response headers sent, body in progress
| ``FAILED`` Unrecoverable state (error while sending response)
|
"""
IDLE = 0 # Waiting for request
REQUEST = 1 # Request headers being received
HANDLER = 3 # Headers done, handler running
RESPONSE = 4 # Response headers sent, body in progress
FAILED = 100 # Unrecoverable state (error while sending response)
HTTP_CONTINUE = b"HTTP/1.1 100 Continue\r\n\r\n"
class Http(Stream, metaclass=TouchUpMeta):
class Http(metaclass=TouchUpMeta):
"""
Internal helper for managing the HTTP/1.1 request/response cycle
Internal helper for managing the HTTP request/response cycle
:raises ServerError:
:raises PayloadTooLarge:
:raises Exception:
:raises BadRequest:
:raises ExpectationFailed:
:raises InvalidUsage:
:raises HeaderExpectationFailed:
:raises RuntimeError:
:raises ServerError:
:raises ServerError:
:raises BadRequest:
:raises BadRequest:
:raises BadRequest:
:raises InvalidUsage:
:raises InvalidUsage:
:raises InvalidUsage:
:raises PayloadTooLarge:
:raises RuntimeError:
"""
HEADER_CEILING = 16_384
HEADER_MAX_SIZE = 0
__touchup__ = (
"http1_request_header",
"http1_response_header",
@@ -229,7 +248,7 @@ class Http(Stream, metaclass=TouchUpMeta):
headers.append(h)
except Exception:
raise BadRequest("Bad Request")
raise InvalidUsage("Bad Request")
headers_instance = Header(headers)
self.upgrade_websocket = (
@@ -246,7 +265,6 @@ class Http(Stream, metaclass=TouchUpMeta):
transport=self.protocol.transport,
app=self.protocol.app,
)
self.protocol.request_class._current.set(request)
await self.dispatch(
"http.lifecycle.request",
inline=True,
@@ -263,7 +281,7 @@ class Http(Stream, metaclass=TouchUpMeta):
if expect.lower() == "100-continue":
self.expecting_continue = True
else:
raise ExpectationFailed(f"Unknown Expect: {expect}")
raise HeaderExpectationFailed(f"Unknown Expect: {expect}")
if headers.getone("transfer-encoding", None) == "chunked":
self.request_body = "chunked"
@@ -334,12 +352,6 @@ class Http(Stream, metaclass=TouchUpMeta):
self.response_func = self.head_response_ignored
headers["connection"] = "keep-alive" if self.keep_alive else "close"
# This header may be removed or modified by the AltSvcCheck Touchup
# service. At server start, we either remove this header from ever
# being assigned, or we change the value as required.
headers["alt-svc"] = ""
ret = format_http1_response(status, res.processed_headers)
if data:
ret += data
@@ -498,7 +510,7 @@ class Http(Stream, metaclass=TouchUpMeta):
if len(buf) > 64:
self.keep_alive = False
raise BadRequest("Bad chunked encoding")
raise InvalidUsage("Bad chunked encoding")
await self._receive_more()
@@ -506,14 +518,14 @@ class Http(Stream, metaclass=TouchUpMeta):
size = int(buf[2:pos].split(b";", 1)[0].decode(), 16)
except Exception:
self.keep_alive = False
raise BadRequest("Bad chunked encoding")
raise InvalidUsage("Bad chunked encoding")
if size <= 0:
self.request_body = None
if size < 0:
self.keep_alive = False
raise BadRequest("Bad chunked encoding")
raise InvalidUsage("Bad chunked encoding")
# Consume CRLF, chunk size 0 and the two CRLF that follow
pos += 4

View File

@@ -1,6 +0,0 @@
from .constants import Stage
from .http1 import Http
from .http3 import Http3
__all__ = ("Http", "Stage", "Http3")

View File

@@ -1,29 +0,0 @@
from enum import Enum, IntEnum
class Stage(Enum):
"""
Enum for representing the stage of the request/response cycle
| ``IDLE`` Waiting for request
| ``REQUEST`` Request headers being received
| ``HANDLER`` Headers done, handler running
| ``RESPONSE`` Response headers sent, body in progress
| ``FAILED`` Unrecoverable state (error while sending response)
|
"""
IDLE = 0 # Waiting for request
REQUEST = 1 # Request headers being received
HANDLER = 3 # Headers done, handler running
RESPONSE = 4 # Response headers sent, body in progress
FAILED = 100 # Unrecoverable state (error while sending response)
class HTTP(IntEnum):
VERSION_1 = 1
VERSION_3 = 3
def display(self) -> str:
value = 1.1 if self.value == 1 else self.value
return f"HTTP/{value}"

View File

@@ -1,406 +0,0 @@
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from ssl import SSLContext
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
cast,
)
from sanic.compat import Header
from sanic.constants import LocalCertCreator
from sanic.exceptions import PayloadTooLarge, SanicException, ServerError
from sanic.helpers import has_message_body
from sanic.http.constants import Stage
from sanic.http.stream import Stream
from sanic.http.tls.context import CertSelector, CertSimple, SanicSSLContext
from sanic.log import Colors, logger
from sanic.models.protocol_types import TransportProtocol
from sanic.models.server_types import ConnInfo
try:
from aioquic.h0.connection import H0_ALPN, H0Connection
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import (
DatagramReceived,
DataReceived,
H3Event,
HeadersReceived,
WebTransportStreamDataReceived,
)
from aioquic.quic.configuration import QuicConfiguration
from aioquic.tls import SessionTicket
HTTP3_AVAILABLE = True
except ModuleNotFoundError: # no cov
HTTP3_AVAILABLE = False
if TYPE_CHECKING:
from sanic import Sanic
from sanic.request import Request
from sanic.response import BaseHTTPResponse
from sanic.server.protocols.http_protocol import Http3Protocol
HttpConnection = Union[H0Connection, H3Connection]
class HTTP3Transport(TransportProtocol):
__slots__ = ("_protocol",)
def __init__(self, protocol: Http3Protocol):
self._protocol = protocol
def get_protocol(self) -> Http3Protocol:
return self._protocol
def get_extra_info(self, info: str, default: Any = None) -> Any:
if (
info in ("socket", "sockname", "peername")
and self._protocol._transport
):
return self._protocol._transport.get_extra_info(info, default)
elif info == "network_paths":
return self._protocol._quic._network_paths
elif info == "ssl_context":
return self._protocol.app.state.ssl
return default
class Receiver(ABC):
future: asyncio.Future
def __init__(self, transmit, protocol, request: Request) -> None:
self.transmit = transmit
self.protocol = protocol
self.request = request
@abstractmethod
async def run(self): # no cov
...
class HTTPReceiver(Receiver, Stream):
stage: Stage
request: Request
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.request_body = None
self.stage = Stage.IDLE
self.headers_sent = False
self.response: Optional[BaseHTTPResponse] = None
self.request_max_size = self.protocol.request_max_size
self.request_bytes = 0
async def run(self, exception: Optional[Exception] = None):
self.stage = Stage.HANDLER
self.head_only = self.request.method.upper() == "HEAD"
if exception:
logger.info( # no cov
f"{Colors.BLUE}[exception]: "
f"{Colors.RED}{exception}{Colors.END}",
exc_info=True,
extra={"verbosity": 1},
)
await self.error_response(exception)
else:
try:
logger.info( # no cov
f"{Colors.BLUE}[request]:{Colors.END} {self.request}",
extra={"verbosity": 1},
)
await self.protocol.request_handler(self.request)
except Exception as e: # no cov
# This should largely be handled within the request handler.
# But, just in case...
await self.run(e)
self.stage = Stage.IDLE
async def error_response(self, exception: Exception) -> None:
"""
Handle response when exception encountered
"""
# From request and handler states we can respond, otherwise be silent
app = self.protocol.app
await app.handle_exception(self.request, exception)
def _prepare_headers(
self, response: BaseHTTPResponse
) -> List[Tuple[bytes, bytes]]:
size = len(response.body) if response.body else 0
headers = response.headers
status = response.status
if not has_message_body(status) and (
size
or "content-length" in headers
or "transfer-encoding" in headers
):
headers.pop("content-length", None)
headers.pop("transfer-encoding", None)
logger.warning( # no cov
f"Message body set in response on {self.request.path}. "
f"A {status} response may only have headers, no body."
)
elif "content-length" not in headers:
if size:
headers["content-length"] = size
else:
headers["transfer-encoding"] = "chunked"
headers = [
(b":status", str(response.status).encode()),
*response.processed_headers,
]
return headers
def send_headers(self) -> None:
logger.debug( # no cov
f"{Colors.BLUE}[send]: {Colors.GREEN}HEADERS{Colors.END}",
extra={"verbosity": 2},
)
if not self.response:
raise RuntimeError("no response")
response = self.response
headers = self._prepare_headers(response)
self.protocol.connection.send_headers(
stream_id=self.request.stream_id,
headers=headers,
)
self.headers_sent = True
self.stage = Stage.RESPONSE
if self.response.body and not self.head_only:
self._send(self.response.body, False)
elif self.head_only:
self.future.cancel()
def respond(self, response: BaseHTTPResponse) -> BaseHTTPResponse:
logger.debug( # no cov
f"{Colors.BLUE}[respond]:{Colors.END} {response}",
extra={"verbosity": 2},
)
if self.stage is not Stage.HANDLER:
self.stage = Stage.FAILED
raise RuntimeError("Response already started")
# Disconnect any earlier but unused response object
if self.response is not None:
self.response.stream = None
self.response, response.stream = response, self
return response
def receive_body(self, data: bytes) -> None:
self.request_bytes += len(data)
if self.request_bytes > self.request_max_size:
raise PayloadTooLarge("Request body exceeds the size limit")
self.request.body += data
async def send(self, data: bytes, end_stream: bool) -> None:
logger.debug( # no cov
f"{Colors.BLUE}[send]: {Colors.GREEN}data={data.decode()} "
f"end_stream={end_stream}{Colors.END}",
extra={"verbosity": 2},
)
self._send(data, end_stream)
def _send(self, data: bytes, end_stream: bool) -> None:
if not self.headers_sent:
self.send_headers()
if self.stage is not Stage.RESPONSE:
raise ServerError(f"not ready to send: {self.stage}")
# Chunked
if (
self.response
and self.response.headers.get("transfer-encoding") == "chunked"
):
size = len(data)
if end_stream:
data = (
b"%x\r\n%b\r\n0\r\n\r\n" % (size, data)
if size
else b"0\r\n\r\n"
)
elif size:
data = b"%x\r\n%b\r\n" % (size, data)
logger.debug( # no cov
f"{Colors.BLUE}[transmitting]{Colors.END}",
extra={"verbosity": 2},
)
self.protocol.connection.send_data(
stream_id=self.request.stream_id,
data=data,
end_stream=end_stream,
)
self.transmit()
if end_stream:
self.stage = Stage.IDLE
class WebsocketReceiver(Receiver): # noqa
async def run(self):
...
class WebTransportReceiver(Receiver): # noqa
async def run(self):
...
class Http3:
"""
Internal helper for managing the HTTP/3 request/response cycle
"""
if HTTP3_AVAILABLE:
HANDLER_PROPERTY_MAPPING = {
DataReceived: "stream_id",
HeadersReceived: "stream_id",
DatagramReceived: "flow_id",
WebTransportStreamDataReceived: "session_id",
}
def __init__(
self,
protocol: Http3Protocol,
transmit: Callable[[], None],
) -> None:
self.protocol = protocol
self.transmit = transmit
self.receivers: Dict[int, Receiver] = {}
def http_event_received(self, event: H3Event) -> None:
logger.debug( # no cov
f"{Colors.BLUE}[http_event_received]: "
f"{Colors.YELLOW}{event}{Colors.END}",
extra={"verbosity": 2},
)
receiver, created_new = self.get_or_make_receiver(event)
receiver = cast(HTTPReceiver, receiver)
if isinstance(event, HeadersReceived) and created_new:
receiver.future = asyncio.ensure_future(receiver.run())
elif isinstance(event, DataReceived):
try:
receiver.receive_body(event.data)
except Exception as e:
receiver.future.cancel()
receiver.future = asyncio.ensure_future(receiver.run(e))
else:
... # Intentionally here to help out Touchup
logger.debug( # no cov
f"{Colors.RED}DOING NOTHING{Colors.END}",
extra={"verbosity": 2},
)
def get_or_make_receiver(self, event: H3Event) -> Tuple[Receiver, bool]:
if (
isinstance(event, HeadersReceived)
and event.stream_id not in self.receivers
):
request = self._make_request(event)
receiver = HTTPReceiver(self.transmit, self.protocol, request)
request.stream = receiver
self.receivers[event.stream_id] = receiver
return receiver, True
else:
ident = getattr(event, self.HANDLER_PROPERTY_MAPPING[type(event)])
return self.receivers[ident], False
def get_receiver_by_stream_id(self, stream_id: int) -> Receiver:
return self.receivers[stream_id]
def _make_request(self, event: HeadersReceived) -> Request:
headers = Header(((k.decode(), v.decode()) for k, v in event.headers))
method = headers[":method"]
path = headers[":path"]
scheme = headers.pop(":scheme", "")
authority = headers.pop(":authority", "")
if authority:
headers["host"] = authority
transport = HTTP3Transport(self.protocol)
request = self.protocol.request_class(
path.encode(),
headers,
"3",
method,
transport,
self.protocol.app,
b"",
)
request.conn_info = ConnInfo(transport)
request._stream_id = event.stream_id
request._scheme = scheme
return request
class SessionTicketStore:
"""
Simple in-memory store for session tickets.
"""
def __init__(self) -> None:
self.tickets: Dict[bytes, SessionTicket] = {}
def add(self, ticket: SessionTicket) -> None:
self.tickets[ticket.ticket] = ticket
def pop(self, label: bytes) -> Optional[SessionTicket]:
return self.tickets.pop(label, None)
def get_config(
app: Sanic, ssl: Union[SanicSSLContext, CertSelector, SSLContext]
):
# TODO:
# - proper selection needed if servince with multiple certs insted of
# just taking the first
if isinstance(ssl, CertSelector):
ssl = cast(SanicSSLContext, ssl.sanic_select[0])
if app.config.LOCAL_CERT_CREATOR is LocalCertCreator.TRUSTME:
raise SanicException(
"Sorry, you cannot currently use trustme as a local certificate "
"generator for an HTTP/3 server. This is not yet supported. You "
"should be able to use mkcert instead. For more information, see: "
"https://github.com/aiortc/aioquic/issues/295."
)
if not isinstance(ssl, CertSimple):
raise SanicException("SSLContext is not CertSimple")
config = QuicConfiguration(
alpn_protocols=H3_ALPN + H0_ALPN + ["siduck"],
is_client=False,
max_datagram_frame_size=65536,
)
password = app.config.TLS_CERT_PASSWORD or None
config.load_cert_chain(
ssl.sanic["cert"], ssl.sanic["key"], password=password
)
return config

View File

@@ -1,27 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Tuple, Union
from sanic.http.constants import Stage
if TYPE_CHECKING:
from sanic.response import BaseHTTPResponse
from sanic.server.protocols.http_protocol import HttpProtocol
class Stream:
stage: Stage
response: Optional[BaseHTTPResponse]
protocol: HttpProtocol
url: Optional[str]
request_body: Optional[bytes]
request_max_size: Union[int, float]
__touchup__: Tuple[str, ...] = tuple()
__slots__ = ()
def respond(
self, response: BaseHTTPResponse
) -> BaseHTTPResponse: # no cov
raise NotImplementedError("Not implemented")

View File

@@ -1,5 +0,0 @@
from .context import process_to_context
from .creators import get_ssl_context
__all__ = ("get_ssl_context", "process_to_context")

View File

@@ -1,278 +0,0 @@
from __future__ import annotations
import ssl
import subprocess
import sys
from abc import ABC, abstractmethod
from contextlib import suppress
from pathlib import Path
from tempfile import mkdtemp
from types import ModuleType
from typing import TYPE_CHECKING, Optional, Tuple, Type, Union, cast
from sanic.application.constants import Mode
from sanic.application.spinner import loading
from sanic.constants import (
DEFAULT_LOCAL_TLS_CERT,
DEFAULT_LOCAL_TLS_KEY,
LocalCertCreator,
)
from sanic.exceptions import SanicException
from sanic.helpers import Default
from sanic.http.tls.context import CertSimple, SanicSSLContext
try:
import trustme
TRUSTME_INSTALLED = True
except (ImportError, ModuleNotFoundError):
trustme = ModuleType("trustme")
TRUSTME_INSTALLED = False
if TYPE_CHECKING:
from sanic import Sanic
# Only allow secure ciphers, notably leaving out AES-CBC mode
# OpenSSL chooses ECDSA or RSA depending on the cert in use
CIPHERS_TLS12 = [
"ECDHE-ECDSA-CHACHA20-POLY1305",
"ECDHE-ECDSA-AES256-GCM-SHA384",
"ECDHE-ECDSA-AES128-GCM-SHA256",
"ECDHE-RSA-CHACHA20-POLY1305",
"ECDHE-RSA-AES256-GCM-SHA384",
"ECDHE-RSA-AES128-GCM-SHA256",
]
def _make_path(maybe_path: Union[Path, str], tmpdir: Optional[Path]) -> Path:
if isinstance(maybe_path, Path):
return maybe_path
else:
path = Path(maybe_path)
if not path.exists():
if not tmpdir:
raise RuntimeError("Reached an unknown state. No tmpdir.")
return tmpdir / maybe_path
return path
def get_ssl_context(
app: Sanic, ssl: Optional[ssl.SSLContext]
) -> ssl.SSLContext:
if ssl:
return ssl
if app.state.mode is Mode.PRODUCTION:
raise SanicException(
"Cannot run Sanic as an HTTPS server in PRODUCTION mode "
"without passing a TLS certificate. If you are developing "
"locally, please enable DEVELOPMENT mode and Sanic will "
"generate a localhost TLS certificate. For more information "
"please see: ___."
)
creator = CertCreator.select(
app,
cast(LocalCertCreator, app.config.LOCAL_CERT_CREATOR),
app.config.LOCAL_TLS_KEY,
app.config.LOCAL_TLS_CERT,
)
context = creator.generate_cert(app.config.LOCALHOST)
return context
class CertCreator(ABC):
def __init__(self, app, key, cert) -> None:
self.app = app
self.key = key
self.cert = cert
self.tmpdir = None
if isinstance(self.key, Default) or isinstance(self.cert, Default):
self.tmpdir = Path(mkdtemp())
key = (
DEFAULT_LOCAL_TLS_KEY
if isinstance(self.key, Default)
else self.key
)
cert = (
DEFAULT_LOCAL_TLS_CERT
if isinstance(self.cert, Default)
else self.cert
)
self.key_path = _make_path(key, self.tmpdir)
self.cert_path = _make_path(cert, self.tmpdir)
@abstractmethod
def check_supported(self) -> None: # no cov
...
@abstractmethod
def generate_cert(self, localhost: str) -> ssl.SSLContext: # no cov
...
@classmethod
def select(
cls,
app: Sanic,
cert_creator: LocalCertCreator,
local_tls_key,
local_tls_cert,
) -> CertCreator:
creator: Optional[CertCreator] = None
cert_creator_options: Tuple[
Tuple[Type[CertCreator], LocalCertCreator], ...
] = (
(MkcertCreator, LocalCertCreator.MKCERT),
(TrustmeCreator, LocalCertCreator.TRUSTME),
)
for creator_class, local_creator in cert_creator_options:
creator = cls._try_select(
app,
creator,
creator_class,
local_creator,
cert_creator,
local_tls_key,
local_tls_cert,
)
if creator:
break
if not creator:
raise SanicException(
"Sanic could not find package to create a TLS certificate. "
"You must have either mkcert or trustme installed. See "
"_____ for more details."
)
return creator
@staticmethod
def _try_select(
app: Sanic,
creator: Optional[CertCreator],
creator_class: Type[CertCreator],
creator_requirement: LocalCertCreator,
creator_requested: LocalCertCreator,
local_tls_key,
local_tls_cert,
):
if creator or (
creator_requested is not LocalCertCreator.AUTO
and creator_requested is not creator_requirement
):
return creator
instance = creator_class(app, local_tls_key, local_tls_cert)
try:
instance.check_supported()
except SanicException:
if creator_requested is creator_requirement:
raise
else:
return None
return instance
class MkcertCreator(CertCreator):
def check_supported(self) -> None:
try:
subprocess.run( # nosec B603 B607
["mkcert", "-help"],
check=True,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
)
except Exception as e:
raise SanicException(
"Sanic is attempting to use mkcert to generate local TLS "
"certificates since you did not supply a certificate, but "
"one is required. Sanic cannot proceed since mkcert does not "
"appear to be installed. Alternatively, you can use trustme. "
"Please install mkcert, trustme, or supply TLS certificates "
"to proceed. Installation instructions can be found here: "
"https://github.com/FiloSottile/mkcert.\n"
"Find out more information about your options here: "
"_____"
) from e
def generate_cert(self, localhost: str) -> ssl.SSLContext:
try:
if not self.cert_path.exists():
message = "Generating TLS certificate"
# TODO: Validate input for security
with loading(message):
cmd = [
"mkcert",
"-key-file",
str(self.key_path),
"-cert-file",
str(self.cert_path),
localhost,
]
resp = subprocess.run( # nosec B603
cmd,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
sys.stdout.write("\r" + " " * (len(message) + 4))
sys.stdout.flush()
sys.stdout.write(resp.stdout)
finally:
@self.app.main_process_stop
async def cleanup(*_): # no cov
if self.tmpdir:
with suppress(FileNotFoundError):
self.key_path.unlink()
self.cert_path.unlink()
self.tmpdir.rmdir()
return CertSimple(self.cert_path, self.key_path)
class TrustmeCreator(CertCreator):
def check_supported(self) -> None:
if not TRUSTME_INSTALLED:
raise SanicException(
"Sanic is attempting to use trustme to generate local TLS "
"certificates since you did not supply a certificate, but "
"one is required. Sanic cannot proceed since trustme does not "
"appear to be installed. Alternatively, you can use mkcert. "
"Please install mkcert, trustme, or supply TLS certificates "
"to proceed. Installation instructions can be found here: "
"https://github.com/python-trio/trustme.\n"
"Find out more information about your options here: "
"_____"
)
def generate_cert(self, localhost: str) -> ssl.SSLContext:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sanic_context = SanicSSLContext.create_from_ssl_context(context)
sanic_context.sanic = {
"cert": self.cert_path.absolute(),
"key": self.key_path.absolute(),
}
ca = trustme.CA()
server_cert = ca.issue_cert(localhost)
server_cert.configure_cert(sanic_context)
ca.configure_trust(context)
ca.cert_pem.write_to_path(str(self.cert_path.absolute()))
server_cert.private_key_and_cert_chain_pem.write_to_path(
str(self.key_path.absolute())
)
return context

View File

@@ -5,10 +5,8 @@ from enum import Enum
from typing import Any, Dict
from warnings import warn
from sanic.compat import is_atty
LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict(
version=1,
disable_existing_loggers=False,
loggers={
@@ -57,53 +55,35 @@ LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
},
},
)
"""
Defult logging configuration
"""
class Colors(str, Enum): # no cov
class Colors(str, Enum):
END = "\033[0m"
BLUE = "\033[01;34m"
GREEN = "\033[01;32m"
PURPLE = "\033[01;35m"
RED = "\033[01;31m"
SANIC = "\033[38;2;255;13;104m"
YELLOW = "\033[01;33m"
RED = "\033[01;31m"
class VerbosityFilter(logging.Filter):
verbosity: int = 0
def filter(self, record: logging.LogRecord) -> bool:
verbosity = getattr(record, "verbosity", 0)
return verbosity <= self.verbosity
_verbosity_filter = VerbosityFilter()
logger = logging.getLogger("sanic.root") # no cov
logger = logging.getLogger("sanic.root")
"""
General Sanic logger
"""
logger.addFilter(_verbosity_filter)
error_logger = logging.getLogger("sanic.error") # no cov
error_logger = logging.getLogger("sanic.error")
"""
Logger used by Sanic for error logging
"""
error_logger.addFilter(_verbosity_filter)
access_logger = logging.getLogger("sanic.access") # no cov
access_logger = logging.getLogger("sanic.access")
"""
Logger used by Sanic for access logging
"""
access_logger.addFilter(_verbosity_filter)
def deprecation(message: str, version: float): # no cov
def deprecation(message: str, version: float):
version_info = f"[DEPRECATION v{version}] "
if is_atty():
if sys.stdout.isatty():
version_info = f"{Colors.RED}{version_info}"
message = f"{Colors.YELLOW}{message}{Colors.END}"
warn(version_info + message, DeprecationWarning)

View File

@@ -1,9 +1,8 @@
from enum import Enum, auto
from functools import partial
from typing import Callable, List, Optional, Union, overload
from typing import List, Optional, Union
from sanic.base.meta import SanicMeta
from sanic.exceptions import BadRequest
from sanic.models.futures import FutureListener
from sanic.models.handler_types import ListenerType, Sanic
@@ -18,8 +17,6 @@ class ListenerEvent(str, Enum):
AFTER_SERVER_STOP = "server.shutdown.after"
MAIN_PROCESS_START = auto()
MAIN_PROCESS_STOP = auto()
RELOAD_PROCESS_START = auto()
RELOAD_PROCESS_STOP = auto()
class ListenerMixin(metaclass=SanicMeta):
@@ -29,33 +26,12 @@ class ListenerMixin(metaclass=SanicMeta):
def _apply_listener(self, listener: FutureListener):
raise NotImplementedError # noqa
@overload
def listener(
self,
listener_or_event: ListenerType[Sanic],
event_or_none: str,
apply: bool = ...,
) -> ListenerType[Sanic]:
...
@overload
def listener(
self,
listener_or_event: str,
event_or_none: None = ...,
apply: bool = ...,
) -> Callable[[ListenerType[Sanic]], ListenerType[Sanic]]:
...
def listener(
self,
listener_or_event: Union[ListenerType[Sanic], str],
event_or_none: Optional[str] = None,
apply: bool = True,
) -> Union[
ListenerType[Sanic],
Callable[[ListenerType[Sanic]], ListenerType[Sanic]],
]:
) -> ListenerType[Sanic]:
"""
Create a listener from a decorated function.
@@ -73,9 +49,7 @@ class ListenerMixin(metaclass=SanicMeta):
:param event: event to listen to
"""
def register_listener(
listener: ListenerType[Sanic], event: str
) -> ListenerType[Sanic]:
def register_listener(listener, event):
nonlocal apply
future_listener = FutureListener(listener, event)
@@ -85,10 +59,6 @@ class ListenerMixin(metaclass=SanicMeta):
return listener
if callable(listener_or_event):
if event_or_none is None:
raise BadRequest(
"Invalid event registration: Missing event name."
)
return register_listener(listener_or_event, event_or_none)
else:
return partial(register_listener, event=listener_or_event)
@@ -103,16 +73,6 @@ class ListenerMixin(metaclass=SanicMeta):
) -> ListenerType[Sanic]:
return self.listener(listener, "main_process_stop")
def reload_process_start(
self, listener: ListenerType[Sanic]
) -> ListenerType[Sanic]:
return self.listener(listener, "reload_process_start")
def reload_process_stop(
self, listener: ListenerType[Sanic]
) -> ListenerType[Sanic]:
return self.listener(listener, "reload_process_stop")
def before_server_start(
self, listener: ListenerType[Sanic]
) -> ListenerType[Sanic]:

View File

@@ -16,9 +16,9 @@ class MiddlewareMixin(metaclass=SanicMeta):
self, middleware_or_request, attach_to="request", apply=True
):
"""
Decorate and register middleware to be called before a request
is handled or after a response is created. Can either be called as
*@app.middleware* or *@app.middleware('request')*.
Decorate and register middleware to be called before a request.
Can either be called as *@app.middleware* or
*@app.middleware('request')*
`See user guide re: middleware
<https://sanicframework.org/guide/basics/middleware.html>`__
@@ -47,25 +47,12 @@ class MiddlewareMixin(metaclass=SanicMeta):
)
def on_request(self, middleware=None):
"""Register a middleware to be called before a request is handled.
This is the same as *@app.middleware('request')*.
:param: middleware: A callable that takes in request.
"""
if callable(middleware):
return self.middleware(middleware, "request")
else:
return partial(self.middleware, attach_to="request")
def on_response(self, middleware=None):
"""Register a middleware to be called after a response is created.
This is the same as *@app.middleware('response')*.
:param: middleware:
A callable that takes in a request and its response.
"""
if callable(middleware):
return self.middleware(middleware, "response")
else:

View File

@@ -7,28 +7,18 @@ from os import path
from pathlib import Path, PurePath
from textwrap import dedent
from time import gmtime, strftime
from typing import (
Any,
Callable,
Iterable,
List,
Optional,
Set,
Tuple,
Union,
cast,
)
from typing import Any, Callable, Iterable, List, Optional, Set, Tuple, Union
from urllib.parse import unquote
from sanic_routing.route import Route
from sanic_routing.route import Route # type: ignore
from sanic.base.meta import SanicMeta
from sanic.compat import stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE, HTTP_METHODS
from sanic.errorpages import RESPONSE_MAPPING
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
from sanic.exceptions import ContentRangeError, FileNotFound, HeaderNotFound
from sanic.handlers import ContentRangeHandler
from sanic.log import error_logger
from sanic.log import deprecation, error_logger
from sanic.models.futures import FutureRoute, FutureStatic
from sanic.models.handler_types import RouteHandler
from sanic.response import HTTPResponse, file, file_stream
@@ -287,7 +277,7 @@ class RouteMixin(metaclass=SanicMeta):
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteHandler:
) -> RouteWrapper:
"""
Add an API URL under the **GET** *HTTP* method
@@ -303,20 +293,17 @@ class RouteMixin(metaclass=SanicMeta):
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method
"""
return cast(
RouteHandler,
self.route(
uri,
methods=frozenset({"GET"}),
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
),
return self.route(
uri,
methods=frozenset({"GET"}),
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
)
def post(
@@ -330,7 +317,7 @@ class RouteMixin(metaclass=SanicMeta):
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteHandler:
) -> RouteWrapper:
"""
Add an API URL under the **POST** *HTTP* method
@@ -346,20 +333,17 @@ class RouteMixin(metaclass=SanicMeta):
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method
"""
return cast(
RouteHandler,
self.route(
uri,
methods=frozenset({"POST"}),
host=host,
strict_slashes=strict_slashes,
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
),
return self.route(
uri,
methods=frozenset({"POST"}),
host=host,
strict_slashes=strict_slashes,
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
)
def put(
@@ -373,7 +357,7 @@ class RouteMixin(metaclass=SanicMeta):
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteHandler:
) -> RouteWrapper:
"""
Add an API URL under the **PUT** *HTTP* method
@@ -389,20 +373,17 @@ class RouteMixin(metaclass=SanicMeta):
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method
"""
return cast(
RouteHandler,
self.route(
uri,
methods=frozenset({"PUT"}),
host=host,
strict_slashes=strict_slashes,
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
),
return self.route(
uri,
methods=frozenset({"PUT"}),
host=host,
strict_slashes=strict_slashes,
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
)
def head(
@@ -416,7 +397,7 @@ class RouteMixin(metaclass=SanicMeta):
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteHandler:
) -> RouteWrapper:
"""
Add an API URL under the **HEAD** *HTTP* method
@@ -440,20 +421,17 @@ class RouteMixin(metaclass=SanicMeta):
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method
"""
return cast(
RouteHandler,
self.route(
uri,
methods=frozenset({"HEAD"}),
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
),
return self.route(
uri,
methods=frozenset({"HEAD"}),
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
)
def options(
@@ -467,7 +445,7 @@ class RouteMixin(metaclass=SanicMeta):
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteHandler:
) -> RouteWrapper:
"""
Add an API URL under the **OPTIONS** *HTTP* method
@@ -491,20 +469,17 @@ class RouteMixin(metaclass=SanicMeta):
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method
"""
return cast(
RouteHandler,
self.route(
uri,
methods=frozenset({"OPTIONS"}),
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
),
return self.route(
uri,
methods=frozenset({"OPTIONS"}),
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
)
def patch(
@@ -518,7 +493,7 @@ class RouteMixin(metaclass=SanicMeta):
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteHandler:
) -> RouteWrapper:
"""
Add an API URL under the **PATCH** *HTTP* method
@@ -544,20 +519,17 @@ class RouteMixin(metaclass=SanicMeta):
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method
"""
return cast(
RouteHandler,
self.route(
uri,
methods=frozenset({"PATCH"}),
host=host,
strict_slashes=strict_slashes,
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
),
return self.route(
uri,
methods=frozenset({"PATCH"}),
host=host,
strict_slashes=strict_slashes,
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
)
def delete(
@@ -571,7 +543,7 @@ class RouteMixin(metaclass=SanicMeta):
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
) -> RouteHandler:
) -> RouteWrapper:
"""
Add an API URL under the **DELETE** *HTTP* method
@@ -587,20 +559,17 @@ class RouteMixin(metaclass=SanicMeta):
will be appended to the route context (``route.ctx``)
:return: Object decorated with :func:`route` method
"""
return cast(
RouteHandler,
self.route(
uri,
methods=frozenset({"DELETE"}),
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
),
return self.route(
uri,
methods=frozenset({"DELETE"}),
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
error_format=error_format,
**ctx_kwargs,
)
def websocket(
@@ -898,7 +867,7 @@ class RouteMixin(metaclass=SanicMeta):
file_path, headers=headers, _range=_range
)
return await file(file_path, headers=headers, _range=_range)
except RangeNotSatisfiable:
except ContentRangeError:
raise
except FileNotFoundError:
raise not_found
@@ -1023,6 +992,17 @@ class RouteMixin(metaclass=SanicMeta):
nonlocal types
with suppress(AttributeError):
if node.value.func.id == "stream": # type: ignore
deprecation(
"The sanic.response.stream method has been "
"deprecated and will be removed in v22.6. Please "
"upgrade your application to use the new style "
"streaming pattern. See "
"https://sanicframework.org/en/guide/advanced/"
"streaming.html#response-streaming for more "
"information.",
22.6,
)
checks = [node.value.func.id] # type: ignore
if node.value.keywords: # type: ignore
checks += [
@@ -1053,7 +1033,7 @@ class RouteMixin(metaclass=SanicMeta):
raise AttributeError(
"Cannot use restricted route context: "
f"{restricted_arguments}. This limitation is only in place "
"until v22.9 when the restricted names will no longer be in"
"until v22.3 when the restricted names will no longer be in"
"conflict. See https://github.com/sanic-org/sanic/issues/2303 "
"for more information."
)

View File

@@ -1,797 +0,0 @@
from __future__ import annotations
import os
import platform
import sys
from asyncio import (
AbstractEventLoop,
CancelledError,
Protocol,
all_tasks,
get_event_loop,
get_running_loop,
new_event_loop,
)
from contextlib import suppress
from functools import partial
from importlib import import_module
from pathlib import Path
from socket import socket
from ssl import SSLContext
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
Optional,
Set,
Tuple,
Type,
Union,
cast,
)
from sanic import reloader_helpers
from sanic.application.logo import get_logo
from sanic.application.motd import MOTD
from sanic.application.state import ApplicationServerInfo, Mode, ServerStage
from sanic.base.meta import SanicMeta
from sanic.compat import OS_IS_WINDOWS, is_atty
from sanic.helpers import _default
from sanic.http.constants import HTTP
from sanic.http.tls import get_ssl_context, process_to_context
from sanic.log import Colors, deprecation, error_logger, logger
from sanic.models.handler_types import ListenerType
from sanic.server import Signal as ServerSignal
from sanic.server import try_use_uvloop
from sanic.server.async_server import AsyncioServer
from sanic.server.events import trigger_events
from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.protocols.websocket_protocol import WebSocketProtocol
from sanic.server.runners import serve, serve_multiple, serve_single
if TYPE_CHECKING:
from sanic import Sanic
from sanic.application.state import ApplicationState
from sanic.config import Config
SANIC_PACKAGES = ("sanic-routing", "sanic-testing", "sanic-ext")
if sys.version_info < (3, 8):
HTTPVersion = Union[HTTP, int]
else:
from typing import Literal
HTTPVersion = Union[HTTP, Literal[1], Literal[3]]
class RunnerMixin(metaclass=SanicMeta):
_app_registry: Dict[str, Sanic]
asgi: bool
config: Config
listeners: Dict[str, List[ListenerType[Any]]]
state: ApplicationState
websocket_enabled: bool
def make_coffee(self, *args, **kwargs):
self.state.coffee = True
self.run(*args, **kwargs)
def run(
self,
host: Optional[str] = None,
port: Optional[int] = None,
*,
dev: bool = False,
debug: bool = False,
auto_reload: Optional[bool] = None,
version: HTTPVersion = HTTP.VERSION_1,
ssl: Union[None, SSLContext, dict, str, list, tuple] = None,
sock: Optional[socket] = None,
workers: int = 1,
protocol: Optional[Type[Protocol]] = None,
backlog: int = 100,
register_sys_signals: bool = True,
access_log: Optional[bool] = None,
unix: Optional[str] = None,
loop: AbstractEventLoop = None,
reload_dir: Optional[Union[List[str], str]] = None,
noisy_exceptions: Optional[bool] = None,
motd: bool = True,
fast: bool = False,
verbosity: int = 0,
motd_display: Optional[Dict[str, str]] = None,
auto_tls: bool = False,
) -> None:
"""
Run the HTTP Server and listen until keyboard interrupt or term
signal. On termination, drain connections before closing.
:param host: Address to host on
:type host: str
:param port: Port to host on
:type port: int
:param debug: Enables debug output (slows server)
:type debug: bool
:param auto_reload: Reload app whenever its source code is changed.
Enabled by default in debug mode.
:type auto_relaod: bool
:param ssl: SSLContext, or location of certificate and key
for SSL encryption of worker(s)
:type ssl: str, dict, SSLContext or list
:param sock: Socket for the server to accept connections from
:type sock: socket
:param workers: Number of processes received before it is respected
:type workers: int
:param protocol: Subclass of asyncio Protocol class
:type protocol: type[Protocol]
:param backlog: a number of unaccepted connections that the system
will allow before refusing new connections
:type backlog: int
:param register_sys_signals: Register SIG* events
:type register_sys_signals: bool
:param access_log: Enables writing access logs (slows server)
:type access_log: bool
:param unix: Unix socket to listen on instead of TCP port
:type unix: str
:param noisy_exceptions: Log exceptions that are normally considered
to be quiet/silent
:type noisy_exceptions: bool
:return: Nothing
"""
self.prepare(
host=host,
port=port,
dev=dev,
debug=debug,
auto_reload=auto_reload,
version=version,
ssl=ssl,
sock=sock,
workers=workers,
protocol=protocol,
backlog=backlog,
register_sys_signals=register_sys_signals,
access_log=access_log,
unix=unix,
loop=loop,
reload_dir=reload_dir,
noisy_exceptions=noisy_exceptions,
motd=motd,
fast=fast,
verbosity=verbosity,
motd_display=motd_display,
auto_tls=auto_tls,
)
self.__class__.serve(primary=self) # type: ignore
def prepare(
self,
host: Optional[str] = None,
port: Optional[int] = None,
*,
dev: bool = False,
debug: bool = False,
auto_reload: Optional[bool] = None,
version: HTTPVersion = HTTP.VERSION_1,
ssl: Union[None, SSLContext, dict, str, list, tuple] = None,
sock: Optional[socket] = None,
workers: int = 1,
protocol: Optional[Type[Protocol]] = None,
backlog: int = 100,
register_sys_signals: bool = True,
access_log: Optional[bool] = None,
unix: Optional[str] = None,
loop: AbstractEventLoop = None,
reload_dir: Optional[Union[List[str], str]] = None,
noisy_exceptions: Optional[bool] = None,
motd: bool = True,
fast: bool = False,
verbosity: int = 0,
motd_display: Optional[Dict[str, str]] = None,
auto_tls: bool = False,
) -> None:
if version == 3 and self.state.server_info:
raise RuntimeError(
"Serving HTTP/3 instances as a secondary server is "
"not supported. There can only be a single HTTP/3 worker "
"and it must be the first instance prepared."
)
if dev:
debug = True
auto_reload = True
self.state.verbosity = verbosity
if not self.state.auto_reload:
self.state.auto_reload = bool(auto_reload)
if fast and workers != 1:
raise RuntimeError("You cannot use both fast=True and workers=X")
if motd_display:
self.config.MOTD_DISPLAY.update(motd_display)
if reload_dir:
if isinstance(reload_dir, str):
reload_dir = [reload_dir]
for directory in reload_dir:
direc = Path(directory)
if not direc.is_dir():
logger.warning(
f"Directory {directory} could not be located"
)
self.state.reload_dirs.add(Path(directory))
if loop is not None:
raise TypeError(
"loop is not a valid argument. To use an existing loop, "
"change to create_server().\nSee more: "
"https://sanic.readthedocs.io/en/latest/sanic/deploying.html"
"#asynchronous-support"
)
if (
self.__class__.should_auto_reload()
and os.environ.get("SANIC_SERVER_RUNNING") != "true"
): # no cov
return
if sock is None:
host, port = self.get_address(host, port, version, auto_tls)
if protocol is None:
protocol = (
WebSocketProtocol if self.websocket_enabled else HttpProtocol
)
# Set explicitly passed configuration values
for attribute, value in {
"ACCESS_LOG": access_log,
"AUTO_RELOAD": auto_reload,
"MOTD": motd,
"NOISY_EXCEPTIONS": noisy_exceptions,
}.items():
if value is not None:
setattr(self.config, attribute, value)
if fast:
self.state.fast = True
try:
workers = len(os.sched_getaffinity(0))
except AttributeError: # no cov
workers = os.cpu_count() or 1
server_settings = self._helper(
host=host,
port=port,
debug=debug,
version=version,
ssl=ssl,
sock=sock,
unix=unix,
workers=workers,
protocol=protocol,
backlog=backlog,
register_sys_signals=register_sys_signals,
auto_tls=auto_tls,
)
self.state.server_info.append(
ApplicationServerInfo(settings=server_settings)
)
if self.config.USE_UVLOOP is True or (
self.config.USE_UVLOOP is _default and not OS_IS_WINDOWS
):
try_use_uvloop()
async def create_server(
self,
host: Optional[str] = None,
port: Optional[int] = None,
*,
debug: bool = False,
ssl: Union[None, SSLContext, dict, str, list, tuple] = None,
sock: Optional[socket] = None,
protocol: Type[Protocol] = None,
backlog: int = 100,
access_log: Optional[bool] = None,
unix: Optional[str] = None,
return_asyncio_server: bool = False,
asyncio_server_kwargs: Dict[str, Any] = None,
noisy_exceptions: Optional[bool] = None,
) -> Optional[AsyncioServer]:
"""
Asynchronous version of :func:`run`.
This method will take care of the operations necessary to invoke
the *before_start* events via :func:`trigger_events` method invocation
before starting the *sanic* app in Async mode.
.. note::
This does not support multiprocessing and is not the preferred
way to run a :class:`Sanic` application.
:param host: Address to host on
:type host: str
:param port: Port to host on
:type port: int
:param debug: Enables debug output (slows server)
:type debug: bool
:param ssl: SSLContext, or location of certificate and key
for SSL encryption of worker(s)
:type ssl: SSLContext or dict
:param sock: Socket for the server to accept connections from
:type sock: socket
:param protocol: Subclass of asyncio Protocol class
:type protocol: type[Protocol]
:param backlog: a number of unaccepted connections that the system
will allow before refusing new connections
:type backlog: int
:param access_log: Enables writing access logs (slows server)
:type access_log: bool
:param return_asyncio_server: flag that defines whether there's a need
to return asyncio.Server or
start it serving right away
:type return_asyncio_server: bool
:param asyncio_server_kwargs: key-value arguments for
asyncio/uvloop create_server method
:type asyncio_server_kwargs: dict
:param noisy_exceptions: Log exceptions that are normally considered
to be quiet/silent
:type noisy_exceptions: bool
:return: AsyncioServer if return_asyncio_server is true, else Nothing
"""
if sock is None:
host, port = host, port = self.get_address(host, port)
if protocol is None:
protocol = (
WebSocketProtocol if self.websocket_enabled else HttpProtocol
)
# Set explicitly passed configuration values
for attribute, value in {
"ACCESS_LOG": access_log,
"NOISY_EXCEPTIONS": noisy_exceptions,
}.items():
if value is not None:
setattr(self.config, attribute, value)
server_settings = self._helper(
host=host,
port=port,
debug=debug,
ssl=ssl,
sock=sock,
unix=unix,
loop=get_event_loop(),
protocol=protocol,
backlog=backlog,
run_async=return_asyncio_server,
)
if self.config.USE_UVLOOP is not _default:
error_logger.warning(
"You are trying to change the uvloop configuration, but "
"this is only effective when using the run(...) method. "
"When using the create_server(...) method Sanic will use "
"the already existing loop."
)
main_start = server_settings.pop("main_start", None)
main_stop = server_settings.pop("main_stop", None)
if main_start or main_stop:
logger.warning(
"Listener events for the main process are not available "
"with create_server()"
)
return await serve(
asyncio_server_kwargs=asyncio_server_kwargs, **server_settings
)
def stop(self):
"""
This kills the Sanic
"""
if self.state.stage is not ServerStage.STOPPED:
self.shutdown_tasks(timeout=0)
for task in all_tasks():
with suppress(AttributeError):
if task.get_name() == "RunServer":
task.cancel()
get_event_loop().stop()
def _helper(
self,
host: Optional[str] = None,
port: Optional[int] = None,
debug: bool = False,
version: HTTPVersion = HTTP.VERSION_1,
ssl: Union[None, SSLContext, dict, str, list, tuple] = None,
sock: Optional[socket] = None,
unix: Optional[str] = None,
workers: int = 1,
loop: AbstractEventLoop = None,
protocol: Type[Protocol] = HttpProtocol,
backlog: int = 100,
register_sys_signals: bool = True,
run_async: bool = False,
auto_tls: bool = False,
) -> Dict[str, Any]:
"""Helper function used by `run` and `create_server`."""
if self.config.PROXIES_COUNT and self.config.PROXIES_COUNT < 0:
raise ValueError(
"PROXIES_COUNT cannot be negative. "
"https://sanic.readthedocs.io/en/latest/sanic/config.html"
"#proxy-configuration"
)
if not self.state.is_debug:
self.state.mode = Mode.DEBUG if debug else Mode.PRODUCTION
if isinstance(version, int):
version = HTTP(version)
ssl = process_to_context(ssl)
if version is HTTP.VERSION_3 or auto_tls:
if TYPE_CHECKING:
self = cast(Sanic, self)
ssl = get_ssl_context(self, ssl)
self.state.host = host or ""
self.state.port = port or 0
self.state.workers = workers
self.state.ssl = ssl
self.state.unix = unix
self.state.sock = sock
server_settings = {
"protocol": protocol,
"host": host,
"port": port,
"version": version,
"sock": sock,
"unix": unix,
"ssl": ssl,
"app": self,
"signal": ServerSignal(),
"loop": loop,
"register_sys_signals": register_sys_signals,
"backlog": backlog,
}
self.motd(server_settings=server_settings)
if is_atty() and not self.state.is_debug:
error_logger.warning(
f"{Colors.YELLOW}Sanic is running in PRODUCTION mode. "
"Consider using '--debug' or '--dev' while actively "
f"developing your application.{Colors.END}"
)
# Register start/stop events
for event_name, settings_name, reverse in (
("main_process_start", "main_start", False),
("main_process_stop", "main_stop", True),
):
listeners = self.listeners[event_name].copy()
if reverse:
listeners.reverse()
# Prepend sanic to the arguments when listeners are triggered
listeners = [partial(listener, self) for listener in listeners]
server_settings[settings_name] = listeners # type: ignore
if run_async:
server_settings["run_async"] = True
return server_settings
def motd(
self,
serve_location: str = "",
server_settings: Optional[Dict[str, Any]] = None,
):
if serve_location:
deprecation(
"Specifying a serve_location in the MOTD is deprecated and "
"will be removed.",
22.9,
)
else:
serve_location = self.get_server_location(server_settings)
if self.config.MOTD:
mode = [f"{self.state.mode},"]
if self.state.fast:
mode.append("goin' fast")
if self.state.asgi:
mode.append("ASGI")
else:
if self.state.workers == 1:
mode.append("single worker")
else:
mode.append(f"w/ {self.state.workers} workers")
if server_settings:
server = ", ".join(
(
self.state.server,
server_settings["version"].display(), # type: ignore
)
)
else:
server = "ASGI" if self.asgi else "unknown" # type: ignore
display = {
"mode": " ".join(mode),
"server": server,
"python": platform.python_version(),
"platform": platform.platform(),
}
extra = {}
if self.config.AUTO_RELOAD:
reload_display = "enabled"
if self.state.reload_dirs:
reload_display += ", ".join(
[
"",
*(
str(path.absolute())
for path in self.state.reload_dirs
),
]
)
display["auto-reload"] = reload_display
packages = []
for package_name in SANIC_PACKAGES:
module_name = package_name.replace("-", "_")
try:
module = import_module(module_name)
packages.append(
f"{package_name}=={module.__version__}" # type: ignore
)
except ImportError:
...
if packages:
display["packages"] = ", ".join(packages)
if self.config.MOTD_DISPLAY:
extra.update(self.config.MOTD_DISPLAY)
logo = get_logo(coffee=self.state.coffee)
MOTD.output(logo, serve_location, display, extra)
@property
def serve_location(self) -> str:
try:
server_settings = self.state.server_info[0].settings
return self.get_server_location(server_settings)
except IndexError:
location = "ASGI" if self.asgi else "unknown" # type: ignore
return f"http://<{location}>"
@staticmethod
def get_server_location(
server_settings: Optional[Dict[str, Any]] = None
) -> str:
serve_location = ""
proto = "http"
if not server_settings:
return serve_location
if server_settings["ssl"] is not None:
proto = "https"
if server_settings["unix"]:
serve_location = f'{server_settings["unix"]} {proto}://...'
elif server_settings["sock"]:
serve_location = (
f'{server_settings["sock"].getsockname()} {proto}://...'
)
elif server_settings["host"] and server_settings["port"]:
# colon(:) is legal for a host only in an ipv6 address
display_host = (
f'[{server_settings["host"]}]'
if ":" in server_settings["host"]
else server_settings["host"]
)
serve_location = (
f'{proto}://{display_host}:{server_settings["port"]}'
)
return serve_location
@staticmethod
def get_address(
host: Optional[str],
port: Optional[int],
version: HTTPVersion = HTTP.VERSION_1,
auto_tls: bool = False,
) -> Tuple[str, int]:
host = host or "127.0.0.1"
port = port or (8443 if (version == 3 or auto_tls) else 8000)
return host, port
@classmethod
def should_auto_reload(cls) -> bool:
return any(app.state.auto_reload for app in cls._app_registry.values())
@classmethod
def serve(cls, primary: Optional[Sanic] = None) -> None:
apps = list(cls._app_registry.values())
if not primary:
try:
primary = apps[0]
except IndexError:
raise RuntimeError("Did not find any applications.")
reloader_start = primary.listeners.get("reload_process_start")
reloader_stop = primary.listeners.get("reload_process_stop")
# We want to run auto_reload if ANY of the applications have it enabled
if (
cls.should_auto_reload()
and os.environ.get("SANIC_SERVER_RUNNING") != "true"
): # no cov
loop = new_event_loop()
trigger_events(reloader_start, loop, primary)
reload_dirs: Set[Path] = primary.state.reload_dirs.union(
*(app.state.reload_dirs for app in apps)
)
reloader_helpers.watchdog(1.0, reload_dirs)
trigger_events(reloader_stop, loop, primary)
return
# This exists primarily for unit testing
if not primary.state.server_info: # no cov
for app in apps:
app.state.server_info.clear()
return
primary_server_info = primary.state.server_info[0]
primary.before_server_start(partial(primary._start_servers, apps=apps))
try:
primary_server_info.stage = ServerStage.SERVING
if primary.state.workers > 1 and os.name != "posix": # no cov
logger.warn(
f"Multiprocessing is currently not supported on {os.name},"
" using workers=1 instead"
)
primary.state.workers = 1
if primary.state.workers == 1:
serve_single(primary_server_info.settings)
elif primary.state.workers == 0:
raise RuntimeError("Cannot serve with no workers")
else:
serve_multiple(
primary_server_info.settings, primary.state.workers
)
except BaseException:
error_logger.exception(
"Experienced exception while trying to serve"
)
raise
finally:
primary_server_info.stage = ServerStage.STOPPED
logger.info("Server Stopped")
for app in apps:
app.state.server_info.clear()
app.router.reset()
app.signal_router.reset()
async def _start_servers(
self,
primary: Sanic,
_,
apps: List[Sanic],
) -> None:
for app in apps:
if (
app.name is not primary.name
and app.state.workers != primary.state.workers
and app.state.server_info
):
message = (
f"The primary application {repr(primary)} is running "
f"with {primary.state.workers} worker(s). All "
"application instances will run with the same number. "
f"You requested {repr(app)} to run with "
f"{app.state.workers} worker(s), which will be ignored "
"in favor of the primary application."
)
if is_atty():
message = "".join(
[
Colors.YELLOW,
message,
Colors.END,
]
)
error_logger.warning(message, exc_info=True)
for server_info in app.state.server_info:
if server_info.stage is not ServerStage.SERVING:
app.state.primary = False
handlers = [
*server_info.settings.pop("main_start", []),
*server_info.settings.pop("main_stop", []),
]
if handlers:
error_logger.warning(
f"Sanic found {len(handlers)} listener(s) on "
"secondary applications attached to the main "
"process. These will be ignored since main "
"process listeners can only be attached to your "
"primary application: "
f"{repr(primary)}"
)
if not server_info.settings["loop"]:
server_info.settings["loop"] = get_running_loop()
try:
server_info.server = await serve(
**server_info.settings,
run_async=True,
reuse_port=bool(primary.state.workers - 1),
)
except OSError as e: # no cov
first_message = (
"An OSError was detected on startup. "
"The encountered error was: "
)
second_message = str(e)
if is_atty():
message_parts = [
Colors.YELLOW,
first_message,
Colors.RED,
second_message,
Colors.END,
]
else:
message_parts = [first_message, second_message]
message = "".join(message_parts)
error_logger.warning(message, exc_info=True)
continue
primary.add_task(
self._run_server(app, server_info), name="RunServer"
)
async def _run_server(
self,
app: RunnerMixin,
server_info: ApplicationServerInfo,
) -> None:
try:
# We should never get to this point without a server
# This is primarily to keep mypy happy
if not server_info.server: # no cov
raise RuntimeError("Could not locate AsyncioServer")
if app.state.stage is ServerStage.STOPPED:
server_info.stage = ServerStage.SERVING
await server_info.server.startup()
await server_info.server.before_start()
await server_info.server.after_start()
await server_info.server.serve_forever()
except CancelledError:
# We should never get to this point without a server
# This is primarily to keep mypy happy
if not server_info.server: # no cov
raise RuntimeError("Could not locate AsyncioServer")
await server_info.server.before_stop()
await server_info.server.close()
await server_info.server.after_stop()
finally:
server_info.stage = ServerStage.STOPPED
server_info.server = None

View File

@@ -3,8 +3,7 @@ import sys
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union
from sanic.exceptions import BadRequest
from sanic.models.protocol_types import TransportProtocol
from sanic.exceptions import InvalidUsage
from sanic.server.websockets.connection import WebSocketConnection
@@ -14,7 +13,7 @@ ASGISend = Callable[[ASGIMessage], Awaitable[None]]
ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
class MockProtocol: # no cov
class MockProtocol:
def __init__(self, transport: "MockTransport", loop):
# This should be refactored when < 3.8 support is dropped
self.transport = transport
@@ -57,7 +56,7 @@ class MockProtocol: # no cov
await self._not_paused.wait()
class MockTransport(TransportProtocol): # no cov
class MockTransport:
_protocol: Optional[MockProtocol]
def __init__(
@@ -69,25 +68,23 @@ class MockTransport(TransportProtocol): # no cov
self._protocol = None
self.loop = None
def get_protocol(self) -> MockProtocol: # type: ignore
def get_protocol(self) -> MockProtocol:
if not self._protocol:
self._protocol = MockProtocol(self, self.loop)
return self._protocol
def get_extra_info(
self, info: str, default=None
) -> Optional[Union[str, bool]]:
def get_extra_info(self, info: str) -> Union[str, bool, None]:
if info == "peername":
return self.scope.get("client")
elif info == "sslcontext":
return self.scope.get("scheme") in ["https", "wss"]
return default
return None
def get_websocket_connection(self) -> WebSocketConnection:
try:
return self._websocket_connection
except AttributeError:
raise BadRequest("Improper websocket connection.")
raise InvalidUsage("Improper websocket connection.")
def create_websocket_connection(
self, send: ASGISend, receive: ASGIReceive

View File

@@ -1,13 +1,11 @@
from asyncio.events import AbstractEventLoop
from typing import Any, Callable, Coroutine, Optional, TypeVar, Union
import sanic
from sanic.request import Request
from sanic.response import BaseHTTPResponse, HTTPResponse
Sanic = TypeVar("Sanic", bound="sanic.Sanic")
Sanic = TypeVar("Sanic")
MiddlewareResponse = Union[
Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]]
@@ -20,9 +18,8 @@ ErrorMiddlewareType = Callable[
[Request, BaseException], Optional[Coroutine[Any, Any, None]]
]
MiddlewareType = Union[RequestMiddlewareType, ResponseMiddlewareType]
ListenerType = Union[
Callable[[Sanic], Optional[Coroutine[Any, Any, None]]],
Callable[[Sanic, AbstractEventLoop], Optional[Coroutine[Any, Any, None]]],
ListenerType = Callable[
[Sanic, AbstractEventLoop], Optional[Coroutine[Any, Any, None]]
]
RouteHandler = Callable[..., Coroutine[Any, Any, Optional[HTTPResponse]]]
SignalHandler = Callable[..., Coroutine[Any, Any, None]]

View File

@@ -1,35 +0,0 @@
from __future__ import annotations
from base64 import b64decode
from dataclasses import dataclass, field
from typing import Optional
@dataclass()
class Credentials:
auth_type: Optional[str]
token: Optional[str]
_username: Optional[str] = field(default=None)
_password: Optional[str] = field(default=None)
def __post_init__(self):
if self._auth_is_basic:
self._username, self._password = (
b64decode(self.token.encode("utf-8")).decode().split(":")
)
@property
def username(self):
if not self._auth_is_basic:
raise AttributeError("Username is available for Basic Auth only")
return self._username
@property
def password(self):
if not self._auth_is_basic:
raise AttributeError("Password is available for Basic Auth only")
return self._password
@property
def _auth_is_basic(self) -> bool:
return self.auth_type == "Basic"

View File

@@ -1,22 +1,28 @@
from __future__ import annotations
import sys
from asyncio import BaseTransport
from typing import TYPE_CHECKING, Any, AnyStr
if TYPE_CHECKING:
from sanic.models.asgi import ASGIScope
from typing import Any, AnyStr, TypeVar, Union
if sys.version_info < (3, 8):
from asyncio import BaseTransport
# from sanic.models.asgi import MockTransport
MockTransport = TypeVar("MockTransport")
TransportProtocol = Union[MockTransport, BaseTransport]
Range = Any
HTMLProtocol = Any
else:
# Protocol is a 3.8+ feature
from typing import Protocol
class TransportProtocol(Protocol):
def get_protocol(self):
...
def get_extra_info(self, info: str) -> Union[str, bool, None]:
...
class HTMLProtocol(Protocol):
def __html__(self) -> AnyStr:
...
@@ -36,8 +42,3 @@ else:
def total(self) -> int:
...
class TransportProtocol(BaseTransport):
scope: ASGIScope
__slots__ = ()

View File

@@ -1,8 +1,6 @@
from __future__ import annotations
from ssl import SSLContext, SSLObject
from ssl import SSLObject
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional
from sanic.models.protocol_types import TransportProtocol
@@ -28,7 +26,6 @@ class ConnInfo:
"sockname",
"ssl",
"cert",
"network_paths",
)
def __init__(self, transport: TransportProtocol, unix=None):
@@ -41,22 +38,17 @@ class ConnInfo:
self.ssl = False
self.server_name = ""
self.cert: Dict[str, Any] = {}
self.network_paths: List[Any] = []
sslobj: Optional[SSLObject] = transport.get_extra_info(
"ssl_object"
) # type: ignore
sslctx: Optional[SSLContext] = transport.get_extra_info(
"ssl_context"
) # type: ignore
if sslobj:
self.ssl = True
self.server_name = getattr(sslobj, "sanic_server_name", None) or ""
self.cert = dict(getattr(sslobj.context, "sanic", {}))
if sslctx and not self.cert:
self.cert = dict(getattr(sslctx, "sanic", {}))
if isinstance(addr, str): # UNIX socket
self.server = unix or addr
return
# IPv4 (ip, port) or IPv6 (ip, port, flowinfo, scopeid)
if isinstance(addr, tuple):
self.server = addr[0] if len(addr) == 2 else f"[{addr[0]}]"
@@ -65,9 +57,6 @@ class ConnInfo:
if addr[1] != (443 if self.ssl else 80):
self.server = f"{self.server}:{addr[1]}"
self.peername = addr = transport.get_extra_info("peername")
self.network_paths = transport.get_extra_info( # type: ignore
"network_paths"
)
if isinstance(addr, tuple):
self.client = addr[0] if len(addr) == 2 else f"[{addr[0]}]"

View File

@@ -77,7 +77,7 @@ def _check_file(filename, mtimes):
return need_reload
def watchdog(sleep_interval, reload_dirs):
def watchdog(sleep_interval, app):
"""Watch project files, restart worker process if a change happened.
:param sleep_interval: interval in second.
@@ -100,7 +100,7 @@ def watchdog(sleep_interval, reload_dirs):
changed = set()
for filename in itertools.chain(
_iter_module_files(),
*(d.glob("**/*") for d in reload_dirs),
*(d.glob("**/*") for d in app.reload_dirs),
):
try:
if _check_file(filename, mtimes):

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
from contextvars import ContextVar
from inspect import isawaitable
from typing import (
TYPE_CHECKING,
Any,
@@ -14,15 +12,10 @@ from typing import (
Union,
)
from sanic_routing.route import Route
from sanic.http.constants import HTTP # type: ignore
from sanic.http.stream import Stream
from sanic.models.asgi import ASGIScope
from sanic.models.http_types import Credentials
from sanic_routing.route import Route # type: ignore
if TYPE_CHECKING:
if TYPE_CHECKING: # no cov
from sanic.server import ConnInfo
from sanic.app import Sanic
@@ -34,23 +27,21 @@ from http.cookies import SimpleCookie
from types import SimpleNamespace
from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse
from httptools import parse_url
from httptools.parser.errors import HttpParserInvalidURLError
from httptools import parse_url # type: ignore
from sanic.compat import CancelledErrors, Header
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.exceptions import BadRequest, BadURL, ServerError
from sanic.exceptions import InvalidUsage, ServerError
from sanic.headers import (
AcceptContainer,
Options,
parse_accept,
parse_content_header,
parse_credentials,
parse_forwarded,
parse_host,
parse_xforwarded,
)
from sanic.http import Stage
from sanic.http import Http, Stage
from sanic.log import error_logger, logger
from sanic.models.protocol_types import TransportProtocol
from sanic.response import BaseHTTPResponse, HTTPResponse
@@ -86,9 +77,6 @@ class Request:
Properties of an HTTP request such as URL, headers, etc.
"""
_current: ContextVar[Request] = ContextVar("request")
_loads = json_loads
__slots__ = (
"__weakref__",
"_cookies",
@@ -98,9 +86,7 @@ class Request:
"_port",
"_protocol",
"_remote_addr",
"_scheme",
"_socket",
"_stream_id",
"_match_info",
"_name",
"app",
@@ -112,13 +98,11 @@ class Request:
"method",
"parsed_accept",
"parsed_args",
"parsed_credentials",
"parsed_not_grouped_args",
"parsed_files",
"parsed_form",
"parsed_forwarded",
"parsed_json",
"parsed_not_grouped_args",
"parsed_token",
"parsed_forwarded",
"raw_url",
"responded",
"request_middleware_started",
@@ -137,17 +121,12 @@ class Request:
transport: TransportProtocol,
app: Sanic,
head: bytes = b"",
stream_id: int = 0,
):
self.raw_url = url_bytes
try:
self._parsed_url = parse_url(url_bytes)
except HttpParserInvalidURLError:
raise BadURL(f"Bad URL: {url_bytes.decode()}")
# TODO: Content-Encoding detection
self._parsed_url = parse_url(url_bytes)
self._id: Optional[Union[uuid.UUID, str, int]] = None
self._name: Optional[str] = None
self._stream_id = stream_id
self.app = app
self.headers = Header(headers)
@@ -162,11 +141,9 @@ class Request:
self.ctx = SimpleNamespace()
self.parsed_forwarded: Optional[Options] = None
self.parsed_accept: Optional[AcceptContainer] = None
self.parsed_credentials: Optional[Credentials] = None
self.parsed_json = None
self.parsed_form: Optional[RequestParameters] = None
self.parsed_files: Optional[RequestParameters] = None
self.parsed_token: Optional[str] = None
self.parsed_form = None
self.parsed_files = None
self.parsed_args: DefaultDict[
Tuple[bool, bool, str, str], RequestParameters
] = defaultdict(RequestParameters)
@@ -174,60 +151,21 @@ class Request:
Tuple[bool, bool, str, str], List[Tuple[str, str]]
] = defaultdict(list)
self.request_middleware_started = False
self.responded: bool = False
self.route: Optional[Route] = None
self.stream: Optional[Stream] = None
self._cookies: Optional[Dict[str, str]] = None
self._match_info: Dict[str, Any] = {}
self.stream: Optional[Http] = None
self.route: Optional[Route] = None
self._protocol = None
self.responded: bool = False
def __repr__(self):
class_name = self.__class__.__name__
return f"<{class_name}: {self.method} {self.path}>"
@classmethod
def get_current(cls) -> Request:
"""
Retrieve the currrent request object
This implements `Context Variables
<https://docs.python.org/3/library/contextvars.html>`_
to allow for accessing the current request from anywhere.
Raises :exc:`sanic.exceptions.ServerError` if it is outside of
a request lifecycle.
.. code-block:: python
from sanic import Request
current_request = Request.get_current()
:return: the current :class:`sanic.request.Request`
"""
request = cls._current.get(None)
if not request:
raise ServerError("No current request")
return request
@classmethod
def generate_id(*_):
return uuid.uuid4()
@property
def stream_id(self):
"""
Access the HTTP/3 stream ID.
Raises :exc:`sanic.exceptions.ServerError` if it is not an
HTTP/3 request.
"""
if self.protocol.version is not HTTP.VERSION_3:
raise ServerError(
"Stream ID is only a property of a HTTP/3 request"
)
return self._stream_id
def reset_response(self):
try:
if (
@@ -251,53 +189,6 @@ class Request:
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
"""Respond to the request without returning.
This method can only be called once, as you can only respond once.
If no ``response`` argument is passed, one will be created from the
``status``, ``headers`` and ``content_type`` arguments.
**The first typical usecase** is if you wish to respond to the
request without returning from the handler:
.. code-block:: python
@app.get("/")
async def handler(request: Request):
data = ... # Process something
json_response = json({"data": data})
await request.respond(json_response)
# You are now free to continue executing other code
...
@app.on_response
async def add_header(_, response: HTTPResponse):
# Middlewares still get executed as expected
response.headers["one"] = "two"
**The second possible usecase** is for when you want to directly
respond to the request:
.. code-block:: python
response = await request.respond(content_type="text/csv")
await response.send("foo,")
await response.send("bar")
# You can control the completion of the response by calling
# the 'eof()' method:
await response.eof()
:param response: response instance to send
:param status: status code to return in the response
:param headers: headers to return in the response
:param content_type: Content-Type header of the response
:return: final response being sent (may be different from the
``response`` parameter because of middlewares) which can be
used to manually send data
"""
try:
if self.stream is not None and self.stream.response:
raise ServerError("Second respond call is not allowed.")
@@ -314,9 +205,6 @@ class Request:
# Connect the response
if isinstance(response, BaseHTTPResponse) and self.stream:
response = self.stream.respond(response)
if isawaitable(response):
response = await response # type: ignore
# Run response middleware
try:
response = await self.app._run_response_middleware(
@@ -344,19 +232,7 @@ class Request:
self.body = b"".join([data async for data in self.stream])
@property
def name(self) -> Optional[str]:
"""
The route name
In the following pattern:
.. code-block::
<AppName>.[<BlueprintName>.]<HandlerName>
:return: Route name
:rtype: Optional[str]
"""
def name(self):
if self._name:
return self._name
elif self.route:
@@ -364,47 +240,26 @@ class Request:
return None
@property
def endpoint(self) -> Optional[str]:
"""
:return: Alias of :attr:`sanic.request.Request.name`
:rtype: Optional[str]
"""
def endpoint(self):
return self.name
@property
def uri_template(self) -> Optional[str]:
"""
:return: The defined URI template
:rtype: Optional[str]
"""
if self.route:
return f"/{self.route.path}"
return None
def uri_template(self):
return f"/{self.route.path}"
@property
def protocol(self):
"""
:return: The HTTP protocol instance
"""
if not self._protocol:
self._protocol = self.transport.get_protocol()
return self._protocol
@property
def raw_headers(self) -> bytes:
"""
:return: The unparsed HTTP headers
:rtype: bytes
"""
def raw_headers(self):
_, headers = self.head.split(b"\r\n", 1)
return bytes(headers)
@property
def request_line(self) -> bytes:
"""
:return: The first line of a HTTP request
:rtype: bytes
"""
def request_line(self):
reqline, _ = self.head.split(b"\r\n", 1)
return bytes(reqline)
@@ -453,131 +308,72 @@ class Request:
return self._id # type: ignore
@property
def json(self) -> Any:
"""
:return: The request body parsed as JSON
:rtype: Any
"""
def json(self):
if self.parsed_json is None:
self.load_json()
return self.parsed_json
def load_json(self, loads=None):
def load_json(self, loads=json_loads):
try:
if not loads:
loads = self.__class__._loads
self.parsed_json = loads(self.body)
except Exception:
if not self.body:
return None
raise BadRequest("Failed when parsing body as json")
raise InvalidUsage("Failed when parsing body as json")
return self.parsed_json
@property
def accept(self) -> AcceptContainer:
"""
:return: The ``Accept`` header parsed
:rtype: AcceptContainer
"""
if self.parsed_accept is None:
accept_header = self.headers.getone("accept", "")
self.parsed_accept = parse_accept(accept_header)
return self.parsed_accept
@property
def token(self) -> Optional[str]:
def token(self):
"""Attempt to return the auth header token.
:return: token related to request
"""
if self.parsed_token is None:
prefixes = ("Bearer", "Token")
_, token = parse_credentials(
self.headers.getone("authorization", None), prefixes
)
self.parsed_token = token
return self.parsed_token
prefixes = ("Bearer", "Token")
auth_header = self.headers.getone("authorization", None)
@property
def credentials(self) -> Optional[Credentials]:
"""Attempt to return the auth header value.
if auth_header is not None:
for prefix in prefixes:
if prefix in auth_header:
return auth_header.partition(prefix)[-1].strip()
Covers NoAuth, Basic Auth, Bearer Token, Api Token authentication
schemas.
:return: A Credentials object with token, or username and password
related to the request
"""
if self.parsed_credentials is None:
try:
prefix, credentials = parse_credentials(
self.headers.getone("authorization", None)
)
if credentials:
self.parsed_credentials = Credentials(
auth_type=prefix, token=credentials
)
except ValueError:
pass
return self.parsed_credentials
def get_form(
self, keep_blank_values: bool = False
) -> Optional[RequestParameters]:
"""
Method to extract and parse the form data from a request.
:param keep_blank_values:
Whether to discard blank values from the form data
:type keep_blank_values: bool
:return: the parsed form data
:rtype: Optional[RequestParameters]
"""
self.parsed_form = RequestParameters()
self.parsed_files = RequestParameters()
content_type = self.headers.getone(
"content-type", DEFAULT_HTTP_CONTENT_TYPE
)
content_type, parameters = parse_content_header(content_type)
try:
if content_type == "application/x-www-form-urlencoded":
self.parsed_form = RequestParameters(
parse_qs(
self.body.decode("utf-8"),
keep_blank_values=keep_blank_values,
)
)
elif content_type == "multipart/form-data":
# TODO: Stream this instead of reading to/from memory
boundary = parameters["boundary"].encode( # type: ignore
"utf-8"
) # type: ignore
self.parsed_form, self.parsed_files = parse_multipart_form(
self.body, boundary
)
except Exception:
error_logger.exception("Failed when parsing form")
return self.parsed_form
return auth_header
@property
def form(self):
"""
:return: The request body parsed as form data
"""
if self.parsed_form is None:
self.get_form()
self.parsed_form = RequestParameters()
self.parsed_files = RequestParameters()
content_type = self.headers.getone(
"content-type", DEFAULT_HTTP_CONTENT_TYPE
)
content_type, parameters = parse_content_header(content_type)
try:
if content_type == "application/x-www-form-urlencoded":
self.parsed_form = RequestParameters(
parse_qs(self.body.decode("utf-8"))
)
elif content_type == "multipart/form-data":
# TODO: Stream this instead of reading to/from memory
boundary = parameters["boundary"].encode("utf-8")
self.parsed_form, self.parsed_files = parse_multipart_form(
self.body, boundary
)
except Exception:
error_logger.exception("Failed when parsing form")
return self.parsed_form
@property
def files(self):
"""
:return: The request body parsed as uploaded files
"""
if self.parsed_files is None:
self.form # compute form to get files
@@ -591,8 +387,8 @@ class Request:
errors: str = "replace",
) -> RequestParameters:
"""
Method to parse ``query_string`` using ``urllib.parse.parse_qs``.
This methods is used by ``args`` property.
Method to parse `query_string` using `urllib.parse.parse_qs`.
This methods is used by `args` property.
Can be used directly if you need to change default parameters.
:param keep_blank_values:
@@ -641,10 +437,6 @@ class Request:
]
args = property(get_args)
"""
Convenience property to access :meth:`Request.get_args` with
default values.
"""
def get_query_args(
self,
@@ -764,9 +556,6 @@ class Request:
@property
def socket(self):
"""
:return: Information about the connected socket if available
"""
return self.conn_info.peername if self.conn_info else (None, None)
@property
@@ -777,13 +566,6 @@ class Request:
"""
return self._parsed_url.path.decode("utf-8")
@property
def network_paths(self):
"""
Access the network paths if available
"""
return self.conn_info.network_paths
# Proxy properties (using SERVER_NAME/forwarded/request/transport info)
@property
@@ -837,25 +619,23 @@ class Request:
:return: http|https|ws|wss or arbitrary value given by the headers.
:rtype: str
"""
if not hasattr(self, "_scheme"):
if "//" in self.app.config.get("SERVER_NAME", ""):
return self.app.config.SERVER_NAME.split("//")[0]
if "proto" in self.forwarded:
return str(self.forwarded["proto"])
if "//" in self.app.config.get("SERVER_NAME", ""):
return self.app.config.SERVER_NAME.split("//")[0]
if "proto" in self.forwarded:
return str(self.forwarded["proto"])
if (
self.app.websocket_enabled
and self.headers.getone("upgrade", "").lower() == "websocket"
):
scheme = "ws"
else:
scheme = "http"
if (
self.app.websocket_enabled
and self.headers.getone("upgrade", "").lower() == "websocket"
):
scheme = "ws"
else:
scheme = "http"
if self.transport.get_extra_info("sslcontext"):
scheme += "s"
self._scheme = scheme
if self.transport.get_extra_info("sslcontext"):
scheme += "s"
return self._scheme
return scheme
@property
def host(self) -> str:
@@ -960,21 +740,6 @@ class Request:
view_name, _external=True, _scheme=scheme, _server=netloc, **kwargs
)
@property
def scope(self) -> ASGIScope:
"""
:return: The ASGI scope of the request.
If the app isn't an ASGI app, then raises an exception.
:rtype: Optional[ASGIScope]
"""
if not self.app.asgi:
raise NotImplementedError(
"App isn't running in ASGI mode. "
"Scope is only available for ASGI apps."
)
return self.transport.scope
class File(NamedTuple):
"""

View File

@@ -1,12 +1,9 @@
from __future__ import annotations
from datetime import datetime
from email.utils import formatdate
from functools import partial
from mimetypes import guess_type
from os import path
from pathlib import PurePath
from time import time
from typing import (
TYPE_CHECKING,
Any,
@@ -22,23 +19,17 @@ from typing import (
)
from urllib.parse import quote_plus
from sanic.compat import Header, open_async, stat_async
from sanic.compat import Header, open_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.cookies import CookieJar
from sanic.exceptions import SanicException, ServerError
from sanic.helpers import (
Default,
_default,
has_message_body,
remove_entity_headers,
)
from sanic.helpers import has_message_body, remove_entity_headers
from sanic.http import Http
from sanic.models.protocol_types import HTMLProtocol, Range
if TYPE_CHECKING:
from sanic.asgi import ASGIApp
from sanic.http.http3 import HTTPReceiver
from sanic.request import Request
else:
Request = TypeVar("Request")
@@ -59,31 +50,17 @@ class BaseHTTPResponse:
The base class for all HTTP Responses
"""
__slots__ = (
"asgi",
"body",
"content_type",
"stream",
"status",
"headers",
"_cookies",
)
_dumps = json_dumps
def __init__(self):
self.asgi: bool = False
self.body: Optional[bytes] = None
self.content_type: Optional[str] = None
self.stream: Optional[Union[Http, ASGIApp, HTTPReceiver]] = None
self.stream: Optional[Union[Http, ASGIApp]] = None
self.status: int = None
self.headers = Header({})
self._cookies: Optional[CookieJar] = None
def __repr__(self):
class_name = self.__class__.__name__
return f"<{class_name}: {self.status} {self.content_type}>"
def _encode_body(self, data: Optional[AnyStr]):
if data is None:
return b""
@@ -162,10 +139,7 @@ class BaseHTTPResponse:
if hasattr(data, "encode")
else data or b""
)
await self.stream.send(
data, # type: ignore
end_stream=end_stream or False,
)
await self.stream.send(data, end_stream=end_stream)
class HTTPResponse(BaseHTTPResponse):
@@ -182,7 +156,7 @@ class HTTPResponse(BaseHTTPResponse):
:type content_type: Optional[str]
"""
__slots__ = ()
__slots__ = ("body", "status", "content_type", "headers", "_cookies")
def __init__(
self,
@@ -325,9 +299,6 @@ async def file(
mime_type: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
filename: Optional[str] = None,
last_modified: Optional[Union[datetime, float, int, Default]] = _default,
max_age: Optional[Union[float, int]] = None,
no_store: Optional[bool] = None,
_range: Optional[Range] = None,
) -> HTTPResponse:
"""Return a response object with file data.
@@ -336,9 +307,6 @@ async def file(
:param mime_type: Specific mime_type.
:param headers: Custom Headers.
:param filename: Override filename.
:param last_modified: The last modified date and time of the file.
:param max_age: Max age for cache control.
:param no_store: Any cache should not store this response.
:param _range:
"""
headers = headers or {}
@@ -346,34 +314,6 @@ async def file(
headers.setdefault(
"Content-Disposition", f'attachment; filename="{filename}"'
)
if isinstance(last_modified, datetime):
last_modified = last_modified.replace(microsecond=0).timestamp()
elif isinstance(last_modified, Default):
stat = await stat_async(location)
last_modified = stat.st_mtime
if last_modified:
headers.setdefault(
"last-modified", formatdate(last_modified, usegmt=True)
)
if no_store:
cache_control = "no-store"
elif max_age:
cache_control = f"public, max-age={max_age}"
headers.setdefault(
"expires",
formatdate(
time() + max_age,
usegmt=True,
),
)
else:
cache_control = "no-cache"
headers.setdefault("cache-control", cache_control)
filename = filename or path.split(location)[-1]
async with await open_async(location, mode="rb") as f:
@@ -427,7 +367,8 @@ def redirect(
class ResponseStream:
"""
ResponseStream is a compat layer to bridge the gap after the deprecation
of StreamingHTTPResponse. It will be removed when:
of StreamingHTTPResponse. In v22.6 it will be removed when:
- stream is removed
- file_stream is moved to new style streaming
- file and file_stream are combined into a single API
"""
@@ -555,3 +496,38 @@ async def file_stream(
headers=headers,
content_type=mime_type,
)
def stream(
streaming_fn: Callable[
[Union[BaseHTTPResponse, ResponseStream]], Coroutine[Any, Any, None]
],
status: int = 200,
headers: Optional[Dict[str, str]] = None,
content_type: str = "text/plain; charset=utf-8",
) -> ResponseStream:
"""Accepts a coroutine `streaming_fn` which can be used to
write chunks to a streaming response. Returns a `ResponseStream`.
Example usage::
@app.route("/")
async def index(request):
async def streaming_fn(response):
await response.write('foo')
await response.write('bar')
return stream(streaming_fn, content_type='text/plain')
:param streaming_fn: A coroutine accepts a response and
writes content to that response.
:param status: HTTP status.
:param content_type: Specific content_type.
:param headers: Custom Headers.
"""
return ResponseStream(
streaming_fn,
headers=headers,
content_type=content_type,
status=status,
)

View File

@@ -5,14 +5,16 @@ from inspect import signature
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
from uuid import UUID
from sanic_routing import BaseRouter
from sanic_routing.exceptions import NoMethod
from sanic_routing.exceptions import NotFound as RoutingNotFound
from sanic_routing.route import Route
from sanic_routing import BaseRouter # type: ignore
from sanic_routing.exceptions import NoMethod # type: ignore
from sanic_routing.exceptions import (
NotFound as RoutingNotFound, # type: ignore
)
from sanic_routing.route import Route # type: ignore
from sanic.constants import HTTP_METHODS
from sanic.errorpages import check_error_format
from sanic.exceptions import MethodNotAllowed, NotFound, SanicException
from sanic.exceptions import MethodNotSupported, NotFound, SanicException
from sanic.models.handler_types import RouteHandler
@@ -41,7 +43,7 @@ class Router(BaseRouter):
except RoutingNotFound as e:
raise NotFound("Requested URL {} not found".format(e.path))
except NoMethod as e:
raise MethodNotAllowed(
raise MethodNotSupported(
"Method {} not allowed for URL {}".format(method, path),
method=method,
allowed_methods=e.allowed_methods,

View File

@@ -5,6 +5,7 @@ import asyncio
from typing import TYPE_CHECKING
from sanic.exceptions import SanicException
from sanic.log import deprecation
if TYPE_CHECKING:
@@ -34,6 +35,15 @@ class AsyncioServer:
self.serve_coro = serve_coro
self.server = None
@property
def init(self):
deprecation(
"AsyncioServer.init has been deprecated and will be removed "
"in v22.6. Use Sanic.state.is_started instead.",
22.6,
)
return self.app.state.is_started
def startup(self):
"""
Trigger "before_server_start" events

View File

@@ -1,18 +1,8 @@
from __future__ import annotations
from inspect import isawaitable
from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional
from typing import Any, Callable, Iterable, Optional
if TYPE_CHECKING:
from sanic import Sanic
def trigger_events(
events: Optional[Iterable[Callable[..., Any]]],
loop,
app: Optional[Sanic] = None,
):
def trigger_events(events: Optional[Iterable[Callable[..., Any]]], loop):
"""
Trigger event callbacks (functions or async)
@@ -21,9 +11,6 @@ def trigger_events(
"""
if events:
for event in events:
try:
result = event() if not app else event(app)
except TypeError:
result = event(loop) if not app else event(app, loop)
result = event(loop)
if isawaitable(result):
loop.run_until_complete(result)

View File

@@ -2,89 +2,33 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sanic.http.constants import HTTP
from sanic.http.http3 import Http3
from sanic.touchup.meta import TouchUpMeta
if TYPE_CHECKING:
from sanic.app import Sanic
import sys
from asyncio import CancelledError
from time import monotonic as current_time
from sanic.exceptions import RequestTimeout, ServiceUnavailable
from sanic.http import Http, Stage
from sanic.log import Colors, error_logger, logger
from sanic.log import error_logger, logger
from sanic.models.server_types import ConnInfo
from sanic.request import Request
from sanic.server.protocols.base_protocol import SanicProtocol
ConnectionProtocol = type("ConnectionProtocol", (), {})
try:
from aioquic.asyncio import QuicConnectionProtocol
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.quic.events import (
DatagramFrameReceived,
ProtocolNegotiated,
QuicEvent,
)
ConnectionProtocol = QuicConnectionProtocol
except ModuleNotFoundError: # no cov
...
class HttpProtocolMixin:
__slots__ = ()
__version__: HTTP
def _setup_connection(self, *args, **kwargs):
self._http = self.HTTP_CLASS(self, *args, **kwargs)
self._time = current_time()
try:
self.check_timeouts()
except AttributeError:
...
def _setup(self):
self.request: Optional[Request] = None
self.access_log = self.app.config.ACCESS_LOG
self.request_handler = self.app.handle_request
self.error_handler = self.app.error_handler
self.request_timeout = self.app.config.REQUEST_TIMEOUT
self.response_timeout = self.app.config.RESPONSE_TIMEOUT
self.keep_alive_timeout = self.app.config.KEEP_ALIVE_TIMEOUT
self.request_max_size = self.app.config.REQUEST_MAX_SIZE
self.request_class = self.app.request_class or Request
@property
def http(self):
if not hasattr(self, "_http"):
return None
return self._http
@property
def version(self):
return self.__class__.__version__
class HttpProtocol(HttpProtocolMixin, SanicProtocol, metaclass=TouchUpMeta):
class HttpProtocol(SanicProtocol, metaclass=TouchUpMeta):
"""
This class provides implements the HTTP 1.1 protocol on top of our
Sanic Server transport
"""
HTTP_CLASS = Http
__touchup__ = (
"send",
"connection_task",
)
__version__ = HTTP.VERSION_1
__slots__ = (
# request params
"request",
@@ -126,12 +70,25 @@ class HttpProtocol(HttpProtocolMixin, SanicProtocol, metaclass=TouchUpMeta):
unix=unix,
)
self.url = None
self.request: Optional[Request] = None
self.access_log = self.app.config.ACCESS_LOG
self.request_handler = self.app.handle_request
self.error_handler = self.app.error_handler
self.request_timeout = self.app.config.REQUEST_TIMEOUT
self.response_timeout = self.app.config.RESPONSE_TIMEOUT
self.keep_alive_timeout = self.app.config.KEEP_ALIVE_TIMEOUT
self.request_max_size = self.app.config.REQUEST_MAX_SIZE
self.request_class = self.app.request_class or Request
self.state = state if state else {}
self._setup()
if "requests_count" not in self.state:
self.state["requests_count"] = 0
self._exception = None
def _setup_connection(self):
self._http = Http(self)
self._time = current_time()
self.check_timeouts()
async def connection_task(self): # no cov
"""
Run a HTTP connection.
@@ -212,10 +169,7 @@ class HttpProtocol(HttpProtocolMixin, SanicProtocol, metaclass=TouchUpMeta):
)
self.loop.call_later(max(0.1, interval), self.check_timeouts)
return
cancel_msg_args = ()
if sys.version_info >= (3, 9):
cancel_msg_args = ("Cancel connection task with a timeout",)
self._task.cancel(*cancel_msg_args)
self._task.cancel()
except Exception:
error_logger.exception("protocol.check_timeouts")
@@ -282,39 +236,3 @@ class HttpProtocol(HttpProtocolMixin, SanicProtocol, metaclass=TouchUpMeta):
self._data_received.set()
except Exception:
error_logger.exception("protocol.data_received")
class Http3Protocol(HttpProtocolMixin, ConnectionProtocol): # type: ignore
HTTP_CLASS = Http3
__version__ = HTTP.VERSION_3
def __init__(self, *args, app: Sanic, **kwargs) -> None:
self.app = app
super().__init__(*args, **kwargs)
self._setup()
self._connection: Optional[H3Connection] = None
def quic_event_received(self, event: QuicEvent) -> None:
logger.debug(
f"{Colors.BLUE}[quic_event_received]: "
f"{Colors.PURPLE}{event}{Colors.END}",
extra={"verbosity": 2},
)
if isinstance(event, ProtocolNegotiated):
self._setup_connection(transmit=self.transmit)
if event.alpn_protocol in H3_ALPN:
self._connection = H3Connection(
self._quic, enable_webtransport=True
)
elif isinstance(event, DatagramFrameReceived):
if event.data == b"quack":
self._quic.send_datagram_frame(b"quack-ack")
# pass event to the HTTP layer
if self._connection is not None:
for http_event in self._connection.handle_event(event):
self._http.http_event_received(http_event)
@property
def connection(self) -> Optional[H3Connection]:
return self._connection

View File

@@ -5,7 +5,7 @@ from websockets.server import ServerConnection
from websockets.typing import Subprotocol
from sanic.exceptions import ServerError
from sanic.log import logger
from sanic.log import deprecation, error_logger
from sanic.server import HttpProtocol
from ..websockets.impl import WebsocketImplProtocol
@@ -29,6 +29,9 @@ class WebSocketProtocol(HttpProtocol):
*args,
websocket_timeout: float = 10.0,
websocket_max_size: Optional[int] = None,
websocket_max_queue: Optional[int] = None, # max_queue is deprecated
websocket_read_limit: Optional[int] = None, # read_limit is deprecated
websocket_write_limit: Optional[int] = None, # write_limit deprecated
websocket_ping_interval: Optional[float] = 20.0,
websocket_ping_timeout: Optional[float] = 20.0,
**kwargs,
@@ -37,6 +40,27 @@ class WebSocketProtocol(HttpProtocol):
self.websocket: Optional[WebsocketImplProtocol] = None
self.websocket_timeout = websocket_timeout
self.websocket_max_size = websocket_max_size
if websocket_max_queue is not None and websocket_max_queue > 0:
# TODO: Reminder remove this warning in v22.3
deprecation(
"Websocket no longer uses queueing, so websocket_max_queue"
" is no longer required.",
22.3,
)
if websocket_read_limit is not None and websocket_read_limit > 0:
# TODO: Reminder remove this warning in v22.3
deprecation(
"Websocket no longer uses read buffers, so "
"websocket_read_limit is not required.",
22.3,
)
if websocket_write_limit is not None and websocket_write_limit > 0:
# TODO: Reminder remove this warning in v22.3
deprecation(
"Websocket no longer uses write buffers, so "
"websocket_write_limit is not required.",
22.3,
)
self.websocket_ping_interval = websocket_ping_interval
self.websocket_ping_timeout = websocket_ping_timeout
@@ -104,7 +128,7 @@ class WebSocketProtocol(HttpProtocol):
max_size=self.websocket_max_size,
subprotocols=subprotocols,
state=OPEN,
logger=logger,
logger=error_logger,
)
resp: "http11.Response" = ws_conn.accept(request)
except Exception:

View File

@@ -6,9 +6,6 @@ from ssl import SSLContext
from typing import TYPE_CHECKING, Dict, Optional, Type, Union
from sanic.config import Config
from sanic.exceptions import ServerError
from sanic.http.constants import HTTP
from sanic.http.tls import get_ssl_context
from sanic.server.events import trigger_events
@@ -26,11 +23,10 @@ from signal import signal as signal_func
from sanic.application.ext import setup_ext
from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows
from sanic.http.http3 import SessionTicketStore, get_config
from sanic.log import error_logger, logger
from sanic.models.server_types import Signal
from sanic.server.async_server import AsyncioServer
from sanic.server.protocols.http_protocol import Http3Protocol, HttpProtocol
from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.socket import (
bind_socket,
bind_unix_socket,
@@ -38,14 +34,6 @@ from sanic.server.socket import (
)
try:
from aioquic.asyncio import serve as quic_serve
HTTP3_AVAILABLE = True
except ModuleNotFoundError: # no cov
HTTP3_AVAILABLE = False
def serve(
host,
port,
@@ -64,7 +52,6 @@ def serve(
signal=Signal(),
state=None,
asyncio_server_kwargs=None,
version=HTTP.VERSION_1,
):
"""Start asynchronous HTTP Server on an individual process.
@@ -101,87 +88,6 @@ def serve(
app.asgi = False
if version is HTTP.VERSION_3:
return _serve_http_3(host, port, app, loop, ssl)
return _serve_http_1(
host,
port,
app,
ssl,
sock,
unix,
reuse_port,
loop,
protocol,
backlog,
register_sys_signals,
run_multiple,
run_async,
connections,
signal,
state,
asyncio_server_kwargs,
)
def _setup_system_signals(
app: Sanic,
run_multiple: bool,
register_sys_signals: bool,
loop: asyncio.AbstractEventLoop,
) -> None:
# Ignore SIGINT when run_multiple
if run_multiple:
signal_func(SIGINT, SIG_IGN)
os.environ["SANIC_WORKER_PROCESS"] = "true"
# Register signals for graceful termination
if register_sys_signals:
if OS_IS_WINDOWS:
ctrlc_workaround_for_windows(app)
else:
for _signal in [SIGTERM] if run_multiple else [SIGINT, SIGTERM]:
loop.add_signal_handler(_signal, app.stop)
def _run_server_forever(loop, before_stop, after_stop, cleanup, unix):
pid = os.getpid()
try:
logger.info("Starting worker [%s]", pid)
loop.run_forever()
except KeyboardInterrupt:
pass
finally:
logger.info("Stopping worker [%s]", pid)
loop.run_until_complete(before_stop())
if cleanup:
cleanup()
loop.run_until_complete(after_stop())
remove_unix_socket(unix)
def _serve_http_1(
host,
port,
app,
ssl,
sock,
unix,
reuse_port,
loop,
protocol,
backlog,
register_sys_signals,
run_multiple,
run_async,
connections,
signal,
state,
asyncio_server_kwargs,
):
connections = connections if connections is not None else set()
protocol_kwargs = _build_protocol_kwargs(protocol, app.config)
server = partial(
@@ -226,10 +132,33 @@ def _serve_http_1(
try:
http_server = loop.run_until_complete(server_coroutine)
except BaseException:
error_logger.exception("Unable to start server", exc_info=True)
error_logger.exception("Unable to start server")
return
def _cleanup():
# Ignore SIGINT when run_multiple
if run_multiple:
signal_func(SIGINT, SIG_IGN)
os.environ["SANIC_WORKER_PROCESS"] = "true"
# Register signals for graceful termination
if register_sys_signals:
if OS_IS_WINDOWS:
ctrlc_workaround_for_windows(app)
else:
for _signal in [SIGTERM] if run_multiple else [SIGINT, SIGTERM]:
loop.add_signal_handler(_signal, app.stop)
loop.run_until_complete(app._server_event("init", "after"))
pid = os.getpid()
try:
logger.info("Starting worker [%s]", pid)
loop.run_forever()
finally:
logger.info("Stopping worker [%s]", pid)
# Run the on_stop function if provided
loop.run_until_complete(app._server_event("shutdown", "before"))
# Wait for event loop to finish and all connections to drain
http_server.close()
loop.run_until_complete(http_server.wait_closed())
@@ -259,55 +188,8 @@ def _serve_http_1(
conn.websocket.fail_connection(code=1001)
else:
conn.abort()
_setup_system_signals(app, run_multiple, register_sys_signals, loop)
loop.run_until_complete(app._server_event("init", "after"))
_run_server_forever(
loop,
partial(app._server_event, "shutdown", "before"),
partial(app._server_event, "shutdown", "after"),
_cleanup,
unix,
)
def _serve_http_3(
host,
port,
app,
loop,
ssl,
register_sys_signals: bool = True,
run_multiple: bool = False,
):
if not HTTP3_AVAILABLE:
raise ServerError(
"Cannot run HTTP/3 server without aioquic installed. "
)
protocol = partial(Http3Protocol, app=app)
ticket_store = SessionTicketStore()
ssl_context = get_ssl_context(app, ssl)
config = get_config(app, ssl_context)
coro = quic_serve(
host,
port,
configuration=config,
create_protocol=protocol,
session_ticket_fetcher=ticket_store.pop,
session_ticket_handler=ticket_store.add,
)
server = AsyncioServer(app, loop, coro, [])
loop.run_until_complete(server.startup())
loop.run_until_complete(server.before_start())
loop.run_until_complete(server)
_setup_system_signals(app, run_multiple, register_sys_signals, loop)
loop.run_until_complete(server.after_start())
# TODO: Create connection cleanup and graceful shutdown
cleanup = None
_run_server_forever(
loop, server.before_stop, server.after_stop, cleanup, None
)
loop.run_until_complete(app._server_event("shutdown", "after"))
remove_unix_socket(unix)
def serve_single(server_settings):

View File

@@ -131,7 +131,7 @@ class WebsocketFrameAssembler:
if self.paused:
self.protocol.resume_frames()
self.paused = False
if not self.get_in_progress: # no cov
if not self.get_in_progress:
# This should be guarded against with the read_mutex,
# exception is here as a failsafe
raise ServerError(
@@ -204,7 +204,7 @@ class WebsocketFrameAssembler:
if self.paused:
self.protocol.resume_frames()
self.paused = False
if not self.get_in_progress: # no cov
if not self.get_in_progress:
# This should be guarded against with the read_mutex,
# exception is here as a failsafe
raise ServerError(
@@ -212,7 +212,7 @@ class WebsocketFrameAssembler:
"asynchronous get was in progress."
)
self.get_in_progress = False
if not self.message_complete.is_set(): # no cov
if not self.message_complete.is_set():
# This should be guarded against with the read_mutex,
# exception is here as a failsafe
raise ServerError(
@@ -220,7 +220,7 @@ class WebsocketFrameAssembler:
"message was complete."
)
self.message_complete.clear()
if self.message_fetched.is_set(): # no cov
if self.message_fetched.is_set():
# This should be guarded against with the read_mutex,
# and get_in_progress check, this exception is
# here as a failsafe

View File

@@ -518,12 +518,8 @@ class WebsocketImplProtocol:
)
try:
self.recv_cancel = asyncio.Future()
tasks = (
self.recv_cancel,
asyncio.ensure_future(self.assembler.get(timeout)),
)
done, pending = await asyncio.wait(
tasks,
(self.recv_cancel, self.assembler.get(timeout)),
return_when=asyncio.FIRST_COMPLETED,
)
done_task = next(iter(done))
@@ -574,12 +570,8 @@ class WebsocketImplProtocol:
self.can_pause = False
self.recv_cancel = asyncio.Future()
while True:
tasks = (
self.recv_cancel,
asyncio.ensure_future(self.assembler.get(timeout=0)),
)
done, pending = await asyncio.wait(
tasks,
(self.recv_cancel, self.assembler.get(timeout=0)),
return_when=asyncio.FIRST_COMPLETED,
)
done_task = next(iter(done))

View File

@@ -6,9 +6,9 @@ from enum import Enum
from inspect import isawaitable
from typing import Any, Dict, List, Optional, Tuple, Union, cast
from sanic_routing import BaseRouter, Route, RouteGroup
from sanic_routing.exceptions import NotFound
from sanic_routing.utils import path_to_parts
from sanic_routing import BaseRouter, Route, RouteGroup # type: ignore
from sanic_routing.exceptions import NotFound # type: ignore
from sanic_routing.utils import path_to_parts # type: ignore
from sanic.exceptions import InvalidSignal
from sanic.log import error_logger, logger
@@ -80,7 +80,6 @@ class SignalRouter(BaseRouter):
group_class=SignalGroup,
stacking=True,
)
self.allow_fail_builtin = True
self.ctx.loop = None
def get( # type: ignore
@@ -130,8 +129,7 @@ class SignalRouter(BaseRouter):
try:
group, handlers, params = self.get(event, condition=condition)
except NotFound as e:
is_reserved = event.split(".", 1)[0] in RESERVED_NAMESPACES
if fail_not_found and (not is_reserved or self.allow_fail_builtin):
if fail_not_found:
raise e
else:
if self.ctx.app.debug and self.ctx.app.state.verbosity >= 1:

View File

@@ -1,9 +1,7 @@
from __future__ import annotations
import os
import ssl
from typing import Any, Dict, Iterable, Optional, Union
from typing import Iterable, Optional, Union
from sanic.log import logger
@@ -79,6 +77,65 @@ def load_cert_dir(p: str) -> ssl.SSLContext:
return CertSimple(certfile, keyfile)
class CertSimple(ssl.SSLContext):
"""A wrapper for creating SSLContext with a sanic attribute."""
def __new__(cls, cert, key, **kw):
# try common aliases, rename to cert/key
certfile = kw["cert"] = kw.pop("certificate", None) or cert
keyfile = kw["key"] = kw.pop("keyfile", None) or key
password = kw.pop("password", None)
if not certfile or not keyfile:
raise ValueError("SSL dict needs filenames for cert and key.")
subject = {}
if "names" not in kw:
cert = ssl._ssl._test_decode_cert(certfile) # type: ignore
kw["names"] = [
name
for t, name in cert["subjectAltName"]
if t in ["DNS", "IP Address"]
]
subject = {k: v for item in cert["subject"] for k, v in item}
self = create_context(certfile, keyfile, password)
self.__class__ = cls
self.sanic = {**subject, **kw}
return self
def __init__(self, cert, key, **kw):
pass # Do not call super().__init__ because it is already initialized
class CertSelector(ssl.SSLContext):
"""Automatically select SSL certificate based on the hostname that the
client is trying to access, via SSL SNI. Paths to certificate folders
with privkey.pem and fullchain.pem in them should be provided, and
will be matched in the order given whenever there is a new connection.
"""
def __new__(cls, ctxs):
return super().__new__(cls)
def __init__(self, ctxs: Iterable[Optional[ssl.SSLContext]]):
super().__init__()
self.sni_callback = selector_sni_callback # type: ignore
self.sanic_select = []
self.sanic_fallback = None
all_names = []
for i, ctx in enumerate(ctxs):
if not ctx:
continue
names = dict(getattr(ctx, "sanic", {})).get("names", [])
all_names += names
self.sanic_select.append(ctx)
if i == 0:
self.sanic_fallback = ctx
if not all_names:
raise ValueError(
"No certificates with SubjectAlternativeNames found."
)
logger.info(f"Certificate vhosts: {', '.join(all_names)}")
def find_cert(self: CertSelector, server_name: str):
"""Find the first certificate that matches the given SNI.
@@ -137,73 +194,3 @@ def server_name_callback(
) -> None:
"""Store the received SNI as sslobj.sanic_server_name."""
sslobj.sanic_server_name = server_name # type: ignore
class SanicSSLContext(ssl.SSLContext):
sanic: Dict[str, os.PathLike]
@classmethod
def create_from_ssl_context(cls, context: ssl.SSLContext):
context.__class__ = cls
return context
class CertSimple(SanicSSLContext):
"""A wrapper for creating SSLContext with a sanic attribute."""
sanic: Dict[str, Any]
def __new__(cls, cert, key, **kw):
# try common aliases, rename to cert/key
certfile = kw["cert"] = kw.pop("certificate", None) or cert
keyfile = kw["key"] = kw.pop("keyfile", None) or key
password = kw.pop("password", None)
if not certfile or not keyfile:
raise ValueError("SSL dict needs filenames for cert and key.")
subject = {}
if "names" not in kw:
cert = ssl._ssl._test_decode_cert(certfile) # type: ignore
kw["names"] = [
name
for t, name in cert["subjectAltName"]
if t in ["DNS", "IP Address"]
]
subject = {k: v for item in cert["subject"] for k, v in item}
self = create_context(certfile, keyfile, password)
self.__class__ = cls
self.sanic = {**subject, **kw}
return self
def __init__(self, cert, key, **kw):
pass # Do not call super().__init__ because it is already initialized
class CertSelector(ssl.SSLContext):
"""Automatically select SSL certificate based on the hostname that the
client is trying to access, via SSL SNI. Paths to certificate folders
with privkey.pem and fullchain.pem in them should be provided, and
will be matched in the order given whenever there is a new connection.
"""
def __new__(cls, ctxs):
return super().__new__(cls)
def __init__(self, ctxs: Iterable[Optional[ssl.SSLContext]]):
super().__init__()
self.sni_callback = selector_sni_callback # type: ignore
self.sanic_select = []
self.sanic_fallback = None
all_names = []
for i, ctx in enumerate(ctxs):
if not ctx:
continue
names = dict(getattr(ctx, "sanic", {})).get("names", [])
all_names += names
self.sanic_select.append(ctx)
if i == 0:
self.sanic_fallback = ctx
if not all_names:
raise ValueError(
"No certificates with SubjectAlternativeNames found."
)
logger.info(f"Certificate vhosts: {', '.join(all_names)}")

View File

@@ -1,4 +1,3 @@
from .altsvc import AltSvcCheck # noqa
from .base import BaseScheme
from .ode import OptionalDispatchEvent # noqa

View File

@@ -1,56 +0,0 @@
from __future__ import annotations
from ast import Assign, Constant, NodeTransformer, Subscript
from typing import TYPE_CHECKING, Any, List
from sanic.http.constants import HTTP
from .base import BaseScheme
if TYPE_CHECKING:
from sanic import Sanic
class AltSvcCheck(BaseScheme):
ident = "ALTSVC"
def visitors(self) -> List[NodeTransformer]:
return [RemoveAltSvc(self.app, self.app.state.verbosity)]
class RemoveAltSvc(NodeTransformer):
def __init__(self, app: Sanic, verbosity: int = 0) -> None:
self._app = app
self._verbosity = verbosity
self._versions = {
info.settings["version"] for info in app.state.server_info
}
def visit_Assign(self, node: Assign) -> Any:
if any(self._matches(target) for target in node.targets):
if self._should_remove():
return None
assert isinstance(node.value, Constant)
node.value.value = self.value()
return node
def _should_remove(self) -> bool:
return len(self._versions) == 1
@staticmethod
def _matches(node) -> bool:
return (
isinstance(node, Subscript)
and isinstance(node.slice, Constant)
and node.slice.value == "alt-svc"
)
def value(self):
values = []
for info in self._app.state.server_info:
port = info.settings["port"]
version = info.settings["version"]
if version is HTTP.VERSION_3:
values.append(f'h3=":{port}"')
return ", ".join(values)

View File

@@ -1,8 +1,5 @@
from abc import ABC, abstractmethod
from ast import NodeTransformer, parse
from inspect import getsource
from textwrap import dedent
from typing import Any, Dict, List, Set, Type
from typing import Set, Type
class BaseScheme(ABC):
@@ -13,26 +10,11 @@ class BaseScheme(ABC):
self.app = app
@abstractmethod
def visitors(self) -> List[NodeTransformer]:
def run(self, method, module_globals) -> None:
...
def __init_subclass__(cls):
BaseScheme._registry.add(cls)
def __call__(self):
return self.visitors()
@classmethod
def build(cls, method, module_globals, app):
raw_source = getsource(method)
src = dedent(raw_source)
node = parse(src)
for scheme in cls._registry:
for visitor in scheme(app)():
node = visitor.visit(node)
compiled_src = compile(node, method.__name__, "exec")
exec_locals: Dict[str, Any] = {}
exec(compiled_src, module_globals, exec_locals) # nosec
return exec_locals[method.__name__]
def __call__(self, method, module_globals):
return self.run(method, module_globals)

View File

@@ -1,5 +1,7 @@
from ast import Attribute, Await, Expr, NodeTransformer
from typing import Any, List
from ast import Attribute, Await, Dict, Expr, NodeTransformer, parse
from inspect import getsource
from textwrap import dedent
from typing import Any
from sanic.log import logger
@@ -8,52 +10,32 @@ from .base import BaseScheme
class OptionalDispatchEvent(BaseScheme):
ident = "ODE"
SYNC_SIGNAL_NAMESPACES = "http."
def __init__(self, app) -> None:
super().__init__(app)
self._sync_events()
self._registered_events = [
signal.name for signal in app.signal_router.routes
signal.path for signal in app.signal_router.routes
]
def visitors(self) -> List[NodeTransformer]:
return [RemoveDispatch(self._registered_events)]
def run(self, method, module_globals):
raw_source = getsource(method)
src = dedent(raw_source)
tree = parse(src)
node = RemoveDispatch(
self._registered_events, self.app.state.verbosity
).visit(tree)
compiled_src = compile(node, method.__name__, "exec")
exec_locals: Dict[str, Any] = {}
exec(compiled_src, module_globals, exec_locals) # nosec
def _sync_events(self):
all_events = set()
app_events = {}
for app in self.app.__class__._app_registry.values():
if app.state.server_info:
app_events[app] = {
signal.name for signal in app.signal_router.routes
}
all_events.update(app_events[app])
for app, events in app_events.items():
missing = {
x
for x in all_events.difference(events)
if any(x.startswith(y) for y in self.SYNC_SIGNAL_NAMESPACES)
}
if missing:
was_finalized = app.signal_router.finalized
if was_finalized: # no cov
app.signal_router.reset()
for event in missing:
app.signal(event)(self.noop)
if was_finalized: # no cov
app.signal_router.finalize()
@staticmethod
async def noop(**_): # no cov
...
return exec_locals[method.__name__]
class RemoveDispatch(NodeTransformer):
def __init__(self, registered_events) -> None:
def __init__(self, registered_events, verbosity: int = 0) -> None:
self._registered_events = registered_events
self._verbosity = verbosity
def visit_Expr(self, node: Expr) -> Any:
call = node.value
@@ -70,10 +52,8 @@ class RemoveDispatch(NodeTransformer):
if hasattr(event, "s"):
event_name = getattr(event, "value", event.s)
if self._not_registered(event_name):
logger.debug(
f"Disabling event: {event_name}",
extra={"verbosity": 2},
)
if self._verbosity >= 2:
logger.debug(f"Disabling event: {event_name}")
return None
return node

View File

@@ -21,8 +21,10 @@ class TouchUp:
module = getmodule(target)
module_globals = dict(getmembers(module))
modified = BaseScheme.build(method, module_globals, app)
setattr(target, method_name, modified)
for scheme in BaseScheme._registry:
modified = scheme(app)(method, module_globals)
setattr(target, method_name, modified)
target.__touched__ = True

View File

@@ -13,7 +13,7 @@ from typing import (
from sanic.models.handler_types import RouteHandler
if TYPE_CHECKING:
if TYPE_CHECKING: # no cov
from sanic import Sanic
from sanic.blueprints import Blueprint

243
sanic/worker.py Normal file
View File

@@ -0,0 +1,243 @@
import asyncio
import logging
import os
import signal
import sys
import traceback
from gunicorn.workers import base # type: ignore
from sanic.compat import UVLOOP_INSTALLED
from sanic.log import logger
from sanic.server import HttpProtocol, Signal, serve, try_use_uvloop
from sanic.server.protocols.websocket_protocol import WebSocketProtocol
try:
import ssl # type: ignore
except ImportError: # no cov
ssl = None # type: ignore
if UVLOOP_INSTALLED: # no cov
try_use_uvloop()
class GunicornWorker(base.Worker):
http_protocol = HttpProtocol
websocket_protocol = WebSocketProtocol
def __init__(self, *args, **kw): # pragma: no cover
super().__init__(*args, **kw)
cfg = self.cfg
if cfg.is_ssl:
self.ssl_context = self._create_ssl_context(cfg)
else:
self.ssl_context = None
self.servers = {}
self.connections = set()
self.exit_code = 0
self.signal = Signal()
def init_process(self):
# create new event_loop after fork
asyncio.get_event_loop().close()
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
super().init_process()
def run(self):
is_debug = self.log.loglevel == logging.DEBUG
protocol = (
self.websocket_protocol
if self.app.callable.websocket_enabled
else self.http_protocol
)
self._server_settings = self.app.callable._helper(
loop=self.loop,
debug=is_debug,
protocol=protocol,
ssl=self.ssl_context,
run_async=True,
)
self._server_settings["signal"] = self.signal
self._server_settings.pop("sock")
self._await(self.app.callable._startup())
self._await(
self.app.callable._server_event("init", "before", loop=self.loop)
)
main_start = self._server_settings.pop("main_start", None)
main_stop = self._server_settings.pop("main_stop", None)
if main_start or main_stop: # noqa
logger.warning(
"Listener events for the main process are not available "
"with GunicornWorker"
)
try:
self._await(self._run())
self.app.callable.is_running = True
self._await(
self.app.callable._server_event(
"init", "after", loop=self.loop
)
)
self.loop.run_until_complete(self._check_alive())
self._await(
self.app.callable._server_event(
"shutdown", "before", loop=self.loop
)
)
self.loop.run_until_complete(self.close())
except BaseException:
traceback.print_exc()
finally:
try:
self._await(
self.app.callable._server_event(
"shutdown", "after", loop=self.loop
)
)
except BaseException:
traceback.print_exc()
finally:
self.loop.close()
sys.exit(self.exit_code)
async def close(self):
if self.servers:
# stop accepting connections
self.log.info(
"Stopping server: %s, connections: %s",
self.pid,
len(self.connections),
)
for server in self.servers:
server.close()
await server.wait_closed()
self.servers.clear()
# prepare connections for closing
self.signal.stopped = True
for conn in self.connections:
conn.close_if_idle()
# gracefully shutdown timeout
start_shutdown = 0
graceful_shutdown_timeout = self.cfg.graceful_timeout
while self.connections and (
start_shutdown < graceful_shutdown_timeout
):
await asyncio.sleep(0.1)
start_shutdown = start_shutdown + 0.1
# Force close non-idle connection after waiting for
# graceful_shutdown_timeout
for conn in self.connections:
if hasattr(conn, "websocket") and conn.websocket:
conn.websocket.fail_connection(code=1001)
else:
conn.abort()
async def _run(self):
for sock in self.sockets:
state = dict(requests_count=0)
self._server_settings["host"] = None
self._server_settings["port"] = None
server = await serve(
sock=sock,
connections=self.connections,
state=state,
**self._server_settings
)
self.servers[server] = state
async def _check_alive(self):
# If our parent changed then we shut down.
pid = os.getpid()
try:
while self.alive:
self.notify()
req_count = sum(
self.servers[srv]["requests_count"] for srv in self.servers
)
if self.max_requests and req_count > self.max_requests:
self.alive = False
self.log.info(
"Max requests exceeded, shutting down: %s", self
)
elif pid == os.getpid() and self.ppid != os.getppid():
self.alive = False
self.log.info("Parent changed, shutting down: %s", self)
else:
await asyncio.sleep(1.0, loop=self.loop)
except (Exception, BaseException, GeneratorExit, KeyboardInterrupt):
pass
@staticmethod
def _create_ssl_context(cfg):
"""Creates SSLContext instance for usage in asyncio.create_server.
See ssl.SSLSocket.__init__ for more details.
"""
ctx = ssl.SSLContext(cfg.ssl_version)
ctx.load_cert_chain(cfg.certfile, cfg.keyfile)
ctx.verify_mode = cfg.cert_reqs
if cfg.ca_certs:
ctx.load_verify_locations(cfg.ca_certs)
if cfg.ciphers:
ctx.set_ciphers(cfg.ciphers)
return ctx
def init_signals(self):
# Set up signals through the event loop API.
self.loop.add_signal_handler(
signal.SIGQUIT, self.handle_quit, signal.SIGQUIT, None
)
self.loop.add_signal_handler(
signal.SIGTERM, self.handle_exit, signal.SIGTERM, None
)
self.loop.add_signal_handler(
signal.SIGINT, self.handle_quit, signal.SIGINT, None
)
self.loop.add_signal_handler(
signal.SIGWINCH, self.handle_winch, signal.SIGWINCH, None
)
self.loop.add_signal_handler(
signal.SIGUSR1, self.handle_usr1, signal.SIGUSR1, None
)
self.loop.add_signal_handler(
signal.SIGABRT, self.handle_abort, signal.SIGABRT, None
)
# Don't let SIGTERM and SIGUSR1 disturb active requests
# by interrupting system calls
signal.siginterrupt(signal.SIGTERM, False)
signal.siginterrupt(signal.SIGUSR1, False)
def handle_quit(self, sig, frame):
self.alive = False
self.app.callable.is_running = False
self.cfg.worker_int(self)
def handle_abort(self, sig, frame):
self.alive = False
self.exit_code = 1
self.cfg.worker_abort(self)
sys.exit(1)
def _await(self, coro):
fut = asyncio.ensure_future(coro, loop=self.loop)
self.loop.run_until_complete(fut)

View File

@@ -1,2 +1,13 @@
[flake8]
ignore = E203, W503
[isort]
atomic = true
default_section = THIRDPARTY
include_trailing_comma = true
known_first_party = sanic
known_third_party = pytest
line_length = 79
lines_after_imports = 2
lines_between_types = 1
multi_line_output = 3

View File

@@ -84,17 +84,17 @@ ujson = "ujson>=1.35" + env_dependency
uvloop = "uvloop>=0.5.3" + env_dependency
types_ujson = "types-ujson" + env_dependency
requirements = [
"sanic-routing>=22.3.0,<22.6.0",
"sanic-routing~=0.7",
"httptools>=0.0.10",
uvloop,
ujson,
"aiofiles>=0.6.0",
"websockets>=10.0",
"multidict>=5.0,<7.0",
"multidict>=5.0,<6.0",
]
tests_require = [
"sanic-testing>=22.3.0",
"sanic-testing>=0.7.0",
"pytest==6.2.5",
"coverage==5.3",
"gunicorn==20.0.4",
@@ -112,7 +112,6 @@ tests_require = [
"docutils",
"pygments",
"uvicorn<0.15.0",
"slotscheck>=0.8.0,<1",
types_ujson,
]
@@ -122,7 +121,6 @@ docs_require = [
"docutils",
"pygments",
"m2r2",
"enum-tools[sphinx]",
"mistune<2.0.0",
]
@@ -150,7 +148,6 @@ extras_require = {
"docs": docs_require,
"all": all_require,
"ext": ["sanic-ext"],
"http3": ["aioquic"],
}
setup_kwargs["install_requires"] = requirements

View File

View File

@@ -1,48 +0,0 @@
"""
For 3.7 compat
"""
from unittest.mock import Mock
class AsyncMock(Mock):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.await_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
parent = super(AsyncMock, self)
async def dummy():
self.await_count += 1
return parent.__call__(*args, **kwargs)
return dummy()
def __await__(self):
return self().__await__()
def reset_mock(self, *args, **kwargs):
super().reset_mock(*args, **kwargs)
self.await_count = 0
def assert_awaited_once(self):
if not self.await_count == 1:
msg = (
f"Expected to have been awaited once."
f" Awaited {self.await_count} times."
)
raise AssertionError(msg)
def assert_awaited_once_with(self, *args, **kwargs):
if not self.await_count == 1:
msg = (
f"Expected to have been awaited once."
f" Awaited {self.await_count} times."
)
raise AssertionError(msg)
self.assert_awaited_once()
return self.assert_called_with(*args, **kwargs)

View File

@@ -1,47 +0,0 @@
import asyncio
from textwrap import dedent
from typing import AnyStr
class RawClient:
CRLF = b"\r\n"
def __init__(self, host: str, port: int):
self.reader = None
self.writer = None
self.host = host
self.port = port
async def connect(self):
self.reader, self.writer = await asyncio.open_connection(
self.host, self.port
)
async def close(self):
self.writer.close()
await self.writer.wait_closed()
async def send(self, message: AnyStr):
if isinstance(message, str):
msg = self._clean(message).encode("utf-8")
else:
msg = message
await self._send(msg)
async def _send(self, message: bytes):
if not self.writer:
raise Exception("No open write stream")
self.writer.write(message)
async def recv(self, nbytes: int = -1) -> bytes:
if not self.reader:
raise Exception("No open read stream")
return await self.reader.read(nbytes)
def _clean(self, message: str) -> str:
return (
dedent(message)
.lstrip("\n")
.replace("\n", self.CRLF.decode("utf-8"))
)

View File

@@ -150,7 +150,6 @@ def app(request):
yield app
for target, method_name in TouchUp._registry:
setattr(target, method_name, CACHE[method_name])
Sanic._app_registry.clear()
@pytest.fixture(scope="function")
@@ -176,21 +175,6 @@ def run_startup(caplog):
return run
@pytest.fixture
def run_multi(caplog):
def run(app, level=logging.DEBUG):
@app.after_server_start
async def stop(app, _):
app.stop()
with caplog.at_level(level):
Sanic.serve()
return caplog.record_tuples
return run
@pytest.fixture(scope="function")
def message_in_records():
def msg_in_log(records: List[LogRecord], msg: str):

View File

@@ -34,12 +34,3 @@ async def shutdown(app: Sanic, _):
def create_app():
return app
def create_app_with_args(args):
try:
print(f"foo={args.foo}")
except AttributeError:
print(f"module={args.module}")
return app

View File

@@ -1,294 +0,0 @@
from unittest.mock import Mock
import pytest
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import DataReceived, HeadersReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.connection import QuicConnection
from aioquic.quic.events import ProtocolNegotiated
from sanic import Request, Sanic
from sanic.compat import Header
from sanic.config import DEFAULT_CONFIG
from sanic.exceptions import PayloadTooLarge
from sanic.http.constants import Stage
from sanic.http.http3 import Http3, HTTPReceiver
from sanic.models.server_types import ConnInfo
from sanic.response import empty, json
from sanic.server.protocols.http_protocol import Http3Protocol
try:
from unittest.mock import AsyncMock
except ImportError:
from tests.asyncmock import AsyncMock # type: ignore
pytestmark = pytest.mark.asyncio
@pytest.fixture(autouse=True)
async def setup(app: Sanic):
@app.get("/")
async def handler(*_):
return empty()
app.router.finalize()
app.signal_router.finalize()
app.signal_router.allow_fail_builtin = False
@pytest.fixture
def http_request(app):
return Request(b"/", Header({}), "3", "GET", Mock(), app)
def generate_protocol(app):
connection = QuicConnection(configuration=QuicConfiguration())
connection._ack_delay = 0
connection._loss = Mock()
connection._loss.spaces = []
connection._loss.get_loss_detection_time = lambda: None
connection.datagrams_to_send = Mock(return_value=[]) # type: ignore
return Http3Protocol(
connection,
app=app,
stream_handler=None,
)
def generate_http_receiver(app, http_request) -> HTTPReceiver:
protocol = generate_protocol(app)
receiver = HTTPReceiver(
protocol.transmit,
protocol,
http_request,
)
http_request.stream = receiver
return receiver
def test_http_receiver_init(app: Sanic, http_request: Request):
receiver = generate_http_receiver(app, http_request)
assert receiver.request_body is None
assert receiver.stage is Stage.IDLE
assert receiver.headers_sent is False
assert receiver.response is None
assert receiver.request_max_size == DEFAULT_CONFIG["REQUEST_MAX_SIZE"]
assert receiver.request_bytes == 0
async def test_http_receiver_run_request(app: Sanic, http_request: Request):
handler = AsyncMock()
class mock_handle(Sanic):
handle_request = handler
app.__class__ = mock_handle
receiver = generate_http_receiver(app, http_request)
receiver.protocol.quic_event_received(
ProtocolNegotiated(alpn_protocol="h3")
)
await receiver.run()
handler.assert_awaited_once_with(receiver.request)
async def test_http_receiver_run_exception(app: Sanic, http_request: Request):
handler = AsyncMock()
class mock_handle(Sanic):
handle_exception = handler
app.__class__ = mock_handle
receiver = generate_http_receiver(app, http_request)
receiver.protocol.quic_event_received(
ProtocolNegotiated(alpn_protocol="h3")
)
exception = Exception("Oof")
await receiver.run(exception)
handler.assert_awaited_once_with(receiver.request, exception)
handler.reset_mock()
receiver.stage = Stage.REQUEST
await receiver.run(exception)
handler.assert_awaited_once_with(receiver.request, exception)
def test_http_receiver_respond(app: Sanic, http_request: Request):
receiver = generate_http_receiver(app, http_request)
response = empty()
receiver.stage = Stage.RESPONSE
with pytest.raises(RuntimeError, match="Response already started"):
receiver.respond(response)
receiver.stage = Stage.HANDLER
receiver.response = Mock()
resp = receiver.respond(response)
assert receiver.response is resp
assert resp is response
assert response.stream is receiver
def test_http_receiver_receive_body(app: Sanic, http_request: Request):
receiver = generate_http_receiver(app, http_request)
receiver.request_max_size = 4
receiver.receive_body(b"..")
assert receiver.request.body == b".."
receiver.receive_body(b"..")
assert receiver.request.body == b"...."
with pytest.raises(
PayloadTooLarge, match="Request body exceeds the size limit"
):
receiver.receive_body(b"..")
def test_http3_events(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
http3.http_event_received(DataReceived(b"foobar", 1, False))
receiver = http3.receivers[1]
assert len(http3.receivers) == 1
assert receiver.request.stream_id == 1
assert receiver.request.path == "/location"
assert receiver.request.method == "GET"
assert receiver.request.headers["foo"] == "bar"
assert receiver.request.body == b"foobar"
async def test_send_headers(app: Sanic, http_request: Request):
send_headers_mock = Mock()
existing_send_headers = H3Connection.send_headers
receiver = generate_http_receiver(app, http_request)
receiver.protocol.quic_event_received(
ProtocolNegotiated(alpn_protocol="h3")
)
http_request._protocol = receiver.protocol
def send_headers(*args, **kwargs):
send_headers_mock(*args, **kwargs)
return existing_send_headers(
receiver.protocol.connection, *args, **kwargs
)
receiver.protocol.connection.send_headers = send_headers
receiver.head_only = False
response = json({}, status=201, headers={"foo": "bar"})
with pytest.raises(RuntimeError, match="no response"):
receiver.send_headers()
receiver.response = response
receiver.send_headers()
assert receiver.headers_sent
assert receiver.stage is Stage.RESPONSE
send_headers_mock.assert_called_once_with(
stream_id=0,
headers=[
(b":status", b"201"),
(b"foo", b"bar"),
(b"content-length", b"2"),
(b"content-type", b"application/json"),
],
)
def test_multiple_streams(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
2,
False,
)
)
receiver1 = http3.get_receiver_by_stream_id(1)
receiver2 = http3.get_receiver_by_stream_id(2)
assert len(http3.receivers) == 2
assert isinstance(receiver1, HTTPReceiver)
assert isinstance(receiver2, HTTPReceiver)
assert receiver1 is not receiver2
def test_request_stream_id(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
receiver = http3.get_receiver_by_stream_id(1)
assert isinstance(receiver.request, Request)
assert receiver.request.stream_id == 1
def test_request_conn_info(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
receiver = http3.get_receiver_by_stream_id(1)
assert isinstance(receiver.request.conn_info, ConnInfo)

View File

@@ -1,114 +0,0 @@
import logging
import sys
from asyncio import Event
from pathlib import Path
import pytest
from sanic import Sanic
from sanic.compat import UVLOOP_INSTALLED
from sanic.http.constants import HTTP
parent_dir = Path(__file__).parent.parent
localhost_dir = parent_dir / "certs/localhost"
@pytest.mark.parametrize("version", (3, HTTP.VERSION_3))
@pytest.mark.skipif(
sys.version_info < (3, 8) and not UVLOOP_INSTALLED,
reason="In 3.7 w/o uvloop the port is not always released",
)
def test_server_starts_http3(app: Sanic, version, caplog):
ev = Event()
@app.after_server_start
def shutdown(*_):
ev.set()
app.stop()
with caplog.at_level(logging.INFO):
app.run(
version=version,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)
assert ev.is_set()
assert (
"sanic.root",
logging.INFO,
"server: sanic, HTTP/3",
) in caplog.record_tuples
@pytest.mark.skipif(
sys.version_info < (3, 8) and not UVLOOP_INSTALLED,
reason="In 3.7 w/o uvloop the port is not always released",
)
def test_server_starts_http1_and_http3(app: Sanic, caplog):
@app.after_server_start
def shutdown(*_):
app.stop()
app.prepare(
version=3,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)
app.prepare(
version=1,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)
with caplog.at_level(logging.INFO):
Sanic.serve()
assert (
"sanic.root",
logging.INFO,
"server: sanic, HTTP/1.1",
) in caplog.record_tuples
assert (
"sanic.root",
logging.INFO,
"server: sanic, HTTP/3",
) in caplog.record_tuples
@pytest.mark.skipif(
sys.version_info < (3, 8) and not UVLOOP_INSTALLED,
reason="In 3.7 w/o uvloop the port is not always released",
)
def test_server_starts_http1_and_http3_bad_order(app: Sanic, caplog):
@app.after_server_start
def shutdown(*_):
app.stop()
app.prepare(
version=1,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)
message = (
"Serving HTTP/3 instances as a secondary server is not supported. "
"There can only be a single HTTP/3 worker and it must be the first "
"instance prepared."
)
with pytest.raises(RuntimeError, match=message):
app.prepare(
version=3,
ssl={
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
)

View File

@@ -1,46 +0,0 @@
from datetime import datetime
from aioquic.tls import CipherSuite, SessionTicket
from sanic.http.http3 import SessionTicketStore
def _generate_ticket(label):
return SessionTicket(
1,
CipherSuite.AES_128_GCM_SHA256,
datetime.now(),
datetime.now(),
label,
label.decode(),
label,
None,
[],
)
def test_session_ticket_store():
store = SessionTicketStore()
assert len(store.tickets) == 0
ticket1 = _generate_ticket(b"foo")
store.add(ticket1)
assert len(store.tickets) == 1
ticket2 = _generate_ticket(b"bar")
store.add(ticket2)
assert len(store.tickets) == 2
assert len(store.tickets) == 2
popped2 = store.pop(ticket2.ticket)
assert len(store.tickets) == 1
assert popped2 is ticket2
popped1 = store.pop(ticket1.ticket)
assert len(store.tickets) == 0
assert popped1 is ticket1

View File

@@ -4,6 +4,7 @@ import re
from collections import Counter
from inspect import isawaitable
from os import environ
from unittest.mock import Mock, patch
import pytest
@@ -112,6 +113,19 @@ def test_create_server_main_convenience(app, caplog):
) in caplog.record_tuples
def test_create_server_init(app, caplog):
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(return_asyncio_server=True)
server = loop.run_until_complete(asyncio_srv_coro)
message = (
"AsyncioServer.init has been deprecated and will be removed in v22.6. "
"Use Sanic.state.is_started instead."
)
with pytest.warns(DeprecationWarning, match=message):
server.init
def test_app_loop_not_running(app):
with pytest.raises(SanicException) as excinfo:
app.loop
@@ -183,7 +197,7 @@ def test_app_enable_websocket(app, websocket_enabled, enable):
assert app.websocket_enabled == True
@patch("sanic.mixins.runner.WebSocketProtocol")
@patch("sanic.app.WebSocketProtocol")
def test_app_websocket_parameters(websocket_protocol_mock, app):
app.config.WEBSOCKET_MAX_SIZE = 44
app.config.WEBSOCKET_PING_TIMEOUT = 48
@@ -371,6 +385,40 @@ def test_get_app_default_ambiguous():
Sanic.get_app()
def test_app_no_registry():
Sanic("no-register", register=False)
with pytest.raises(
SanicException, match='Sanic app name "no-register" not found.'
):
Sanic.get_app("no-register")
def test_app_no_registry_deprecation_message():
with pytest.warns(DeprecationWarning) as records:
Sanic("no-register", register=False)
Sanic("yes-register", register=True)
message = (
"[DEPRECATION v22.6] The register argument is deprecated and will "
"stop working in v22.6. After v22.6 all apps will be added to the "
"Sanic app registry."
)
assert len(records) == 2
for record in records:
assert record.message.args[0] == message
def test_app_no_registry_env():
environ["SANIC_REGISTER"] = "False"
Sanic("no-register")
with pytest.raises(
SanicException, match='Sanic app name "no-register" not found.'
):
Sanic.get_app("no-register")
del environ["SANIC_REGISTER"]
def test_app_set_attribute_warning(app):
message = (
"Setting variables on Sanic instances is not allowed. You should "
@@ -425,14 +473,13 @@ def test_custom_context():
assert app.ctx == ctx
@pytest.mark.parametrize("use", (False, True))
def test_uvloop_config(app, monkeypatch, use):
def test_uvloop_config(app, monkeypatch):
@app.get("/test")
def handler(request):
return text("ok")
try_use_uvloop = Mock()
monkeypatch.setattr(sanic.mixins.runner, "try_use_uvloop", try_use_uvloop)
monkeypatch.setattr(sanic.app, "try_use_uvloop", try_use_uvloop)
# Default config
app.test_client.get("/test")
@@ -442,13 +489,14 @@ def test_uvloop_config(app, monkeypatch, use):
try_use_uvloop.assert_called_once()
try_use_uvloop.reset_mock()
app.config["USE_UVLOOP"] = use
app.config["USE_UVLOOP"] = False
app.test_client.get("/test")
try_use_uvloop.assert_not_called()
if use:
try_use_uvloop.assert_called_once()
else:
try_use_uvloop.assert_not_called()
try_use_uvloop.reset_mock()
app.config["USE_UVLOOP"] = True
app.test_client.get("/test")
try_use_uvloop.assert_called_once()
def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
@@ -458,7 +506,7 @@ def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
apps[2].config.USE_UVLOOP = True
try_use_uvloop = Mock()
monkeypatch.setattr(sanic.mixins.runner, "try_use_uvloop", try_use_uvloop)
monkeypatch.setattr(sanic.app, "try_use_uvloop", try_use_uvloop)
loop = asyncio.get_event_loop()
@@ -521,8 +569,3 @@ def test_cannot_run_fast_and_workers(app):
message = "You cannot use both fast=True and workers=X"
with pytest.raises(RuntimeError, match=message):
app.run(fast=True, workers=4)
def test_no_workers(app):
with pytest.raises(RuntimeError, match="Cannot serve with no workers"):
app.run(workers=0)

View File

@@ -1,5 +1,4 @@
import asyncio
import logging
from collections import deque, namedtuple
@@ -7,13 +6,11 @@ import pytest
import uvicorn
from sanic import Sanic
from sanic.application.state import Mode
from sanic.asgi import MockTransport
from sanic.exceptions import BadRequest, Forbidden, ServiceUnavailable
from sanic.exceptions import Forbidden, InvalidUsage, ServiceUnavailable
from sanic.request import Request
from sanic.response import json, text
from sanic.server.websockets.connection import WebSocketConnection
from sanic.signals import RESERVED_NAMESPACES
@pytest.fixture
@@ -47,7 +44,7 @@ def protocol(transport):
return transport.get_protocol()
def test_listeners_triggered(caplog):
def test_listeners_triggered():
app = Sanic("app")
before_server_start = False
after_server_start = False
@@ -85,31 +82,9 @@ def test_listeners_triggered(caplog):
config = uvicorn.Config(app=app, loop="asyncio", limit_max_requests=0)
server = CustomServer(config=config)
start_message = (
'You have set a listener for "before_server_start" in ASGI mode. '
"It will be executed as early as possible, but not before the ASGI "
"server is started."
)
stop_message = (
'You have set a listener for "after_server_stop" in ASGI mode. '
"It will be executed as late as possible, but not after the ASGI "
"server is stopped."
)
with caplog.at_level(logging.DEBUG):
with pytest.warns(UserWarning):
server.run()
assert (
"sanic.root",
logging.DEBUG,
start_message,
) not in caplog.record_tuples
assert (
"sanic.root",
logging.DEBUG,
stop_message,
) not in caplog.record_tuples
all_tasks = asyncio.all_tasks(asyncio.get_event_loop())
for task in all_tasks:
task.cancel()
@@ -119,38 +94,8 @@ def test_listeners_triggered(caplog):
assert before_server_stop
assert after_server_stop
app.state.mode = Mode.DEBUG
with caplog.at_level(logging.DEBUG):
server.run()
assert (
"sanic.root",
logging.DEBUG,
start_message,
) not in caplog.record_tuples
assert (
"sanic.root",
logging.DEBUG,
stop_message,
) not in caplog.record_tuples
app.state.verbosity = 2
with caplog.at_level(logging.DEBUG):
server.run()
assert (
"sanic.root",
logging.DEBUG,
start_message,
) in caplog.record_tuples
assert (
"sanic.root",
logging.DEBUG,
stop_message,
) in caplog.record_tuples
def test_listeners_triggered_async(app, caplog):
def test_listeners_triggered_async(app):
before_server_start = False
after_server_start = False
before_server_stop = False
@@ -187,31 +132,9 @@ def test_listeners_triggered_async(app, caplog):
config = uvicorn.Config(app=app, loop="asyncio", limit_max_requests=0)
server = CustomServer(config=config)
start_message = (
'You have set a listener for "before_server_start" in ASGI mode. '
"It will be executed as early as possible, but not before the ASGI "
"server is started."
)
stop_message = (
'You have set a listener for "after_server_stop" in ASGI mode. '
"It will be executed as late as possible, but not after the ASGI "
"server is stopped."
)
with caplog.at_level(logging.DEBUG):
with pytest.warns(UserWarning):
server.run()
assert (
"sanic.root",
logging.DEBUG,
start_message,
) not in caplog.record_tuples
assert (
"sanic.root",
logging.DEBUG,
stop_message,
) not in caplog.record_tuples
all_tasks = asyncio.all_tasks(asyncio.get_event_loop())
for task in all_tasks:
task.cancel()
@@ -221,37 +144,6 @@ def test_listeners_triggered_async(app, caplog):
assert before_server_stop
assert after_server_stop
app.state.mode = Mode.DEBUG
app.state.verbosity = 0
with caplog.at_level(logging.DEBUG):
server.run()
assert (
"sanic.root",
logging.DEBUG,
start_message,
) not in caplog.record_tuples
assert (
"sanic.root",
logging.DEBUG,
stop_message,
) not in caplog.record_tuples
app.state.verbosity = 2
with caplog.at_level(logging.DEBUG):
server.run()
assert (
"sanic.root",
logging.DEBUG,
start_message,
) in caplog.record_tuples
assert (
"sanic.root",
logging.DEBUG,
stop_message,
) in caplog.record_tuples
def test_non_default_uvloop_config_raises_warning(app):
app.config.USE_UVLOOP = True
@@ -394,7 +286,7 @@ async def test_websocket_accept_with_multiple_subprotocols(
def test_improper_websocket_connection(transport, send, receive):
with pytest.raises(BadRequest):
with pytest.raises(InvalidUsage):
transport.get_websocket_connection()
transport.create_websocket_connection(send, receive)
@@ -417,7 +309,7 @@ async def test_request_class_custom():
class MyCustomRequest(Request):
pass
app = Sanic(name="Test", request_class=MyCustomRequest)
app = Sanic(name=__name__, request_class=MyCustomRequest)
@app.get("/custom")
def custom_request(request):
@@ -515,44 +407,3 @@ async def test_request_exception_suppressed_by_middleware(app):
_, response = await app.asgi_client.get("/error-prone")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_signals_triggered(app):
@app.get("/test_signals_triggered")
async def _request(request):
return text("test_signals_triggered")
signals_triggered = []
signals_expected = [
# "http.lifecycle.begin",
# "http.lifecycle.read_head",
"http.lifecycle.request",
"http.lifecycle.handle",
"http.routing.before",
"http.routing.after",
"http.lifecycle.response",
# "http.lifecycle.send",
# "http.lifecycle.complete",
]
def signal_handler(signal):
return lambda *a, **kw: signals_triggered.append(signal)
for signal in RESERVED_NAMESPACES["http"]:
app.signal(signal)(signal_handler(signal))
_, response = await app.asgi_client.get("/test_signals_triggered")
assert response.status_code == 200
assert response.text == "test_signals_triggered"
assert signals_triggered == signals_expected
@pytest.mark.asyncio
async def test_asgi_serve_location(app):
@app.get("/")
def _request(request: Request):
return text(request.app.serve_location)
_, response = await app.asgi_client.get("/")
assert response.text == "http://<ASGI>"

View File

@@ -3,7 +3,12 @@ from pytest import raises
from sanic.app import Sanic
from sanic.blueprint_group import BlueprintGroup
from sanic.blueprints import Blueprint
from sanic.exceptions import BadRequest, Forbidden, SanicException, ServerError
from sanic.exceptions import (
Forbidden,
InvalidUsage,
SanicException,
ServerError,
)
from sanic.request import Request
from sanic.response import HTTPResponse, text
@@ -99,7 +104,7 @@ def test_bp_group(app: Sanic):
@blueprint_1.route("/invalid")
def blueprint_1_error(request: Request):
raise BadRequest("Invalid")
raise InvalidUsage("Invalid")
@blueprint_2.route("/")
def blueprint_2_default_route(request):
@@ -115,7 +120,7 @@ def test_bp_group(app: Sanic):
blueprint_3 = Blueprint("blueprint_3", url_prefix="/bp3")
@blueprint_group_1.exception(BadRequest)
@blueprint_group_1.exception(InvalidUsage)
def handle_group_exception(request, exception):
return text("BP1_ERR_OK")

View File

@@ -7,7 +7,12 @@ import pytest
from sanic.app import Sanic
from sanic.blueprints import Blueprint
from sanic.constants import HTTP_METHODS
from sanic.exceptions import BadRequest, NotFound, SanicException, ServerError
from sanic.exceptions import (
InvalidUsage,
NotFound,
SanicException,
ServerError,
)
from sanic.request import Request
from sanic.response import json, text
@@ -443,7 +448,7 @@ def test_bp_exception_handler(app):
@blueprint.route("/1")
def handler_1(request):
raise BadRequest("OK")
raise InvalidUsage("OK")
@blueprint.route("/2")
def handler_2(request):

View File

@@ -2,7 +2,6 @@ import json
import subprocess
from pathlib import Path
from typing import List, Optional, Tuple
import pytest
@@ -11,7 +10,7 @@ from sanic_routing import __version__ as __routing_version__
from sanic import __version__
def capture(command: List[str]):
def capture(command):
proc = subprocess.Popen(
command,
stdout=subprocess.PIPE,
@@ -19,38 +18,37 @@ def capture(command: List[str]):
cwd=Path(__file__).parent,
)
try:
out, err = proc.communicate(timeout=10)
out, err = proc.communicate(timeout=1)
except subprocess.TimeoutExpired:
proc.kill()
out, err = proc.communicate()
return out, err, proc.returncode
def starting_line(lines: List[str]):
def starting_line(lines):
for idx, line in enumerate(lines):
if line.strip().startswith(b"Sanic v"):
return idx
return 0
def read_app_info(lines: List[str]):
def read_app_info(lines):
for line in lines:
if line.startswith(b"{") and line.endswith(b"}"):
return json.loads(line)
@pytest.mark.parametrize(
"appname,extra",
"appname",
(
("fake.server.app", None),
("fake.server:create_app", "--factory"),
("fake.server.create_app()", None),
"fake.server.app",
"fake.server:app",
"fake.server:create_app()",
"fake.server.create_app()",
),
)
def test_server_run(appname: str, extra: Optional[str]):
def test_server_run(appname):
command = ["sanic", appname]
if extra:
command.append(extra)
out, err, exitcode = capture(command)
lines = out.split(b"\n")
firstline = lines[starting_line(lines) + 1]
@@ -59,49 +57,6 @@ def test_server_run(appname: str, extra: Optional[str]):
assert firstline == b"Goin' Fast @ http://127.0.0.1:8000"
def test_server_run_factory_with_args():
command = [
"sanic",
"fake.server.create_app_with_args",
"--factory",
]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
assert exitcode != 1, lines
assert b"module=fake.server.create_app_with_args" in lines
def test_server_run_factory_with_args_arbitrary():
command = [
"sanic",
"fake.server.create_app_with_args",
"--factory",
"--foo=bar",
]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
assert exitcode != 1, lines
assert b"foo=bar" in lines
def test_error_with_function_as_instance_without_factory_arg():
command = ["sanic", "fake.server.create_app"]
out, err, exitcode = capture(command)
assert b"try: \nsanic fake.server.create_app --factory" in err
assert exitcode != 1
def test_error_with_path_as_instance_without_simple_arg():
command = ["sanic", "./fake/"]
out, err, exitcode = capture(command)
assert (
b"Please use --simple if you are passing a directory to sanic." in err
)
assert exitcode != 1
@pytest.mark.parametrize(
"cmd",
(
@@ -120,7 +75,7 @@ def test_error_with_path_as_instance_without_simple_arg():
),
),
)
def test_tls_options(cmd: Tuple[str]):
def test_tls_options(cmd):
command = ["sanic", "fake.server.app", *cmd, "-p=9999", "--debug"]
out, err, exitcode = capture(command)
assert exitcode != 1
@@ -141,14 +96,15 @@ def test_tls_options(cmd: Tuple[str]):
("--tls-strict-host",),
),
)
def test_tls_wrong_options(cmd: Tuple[str]):
def test_tls_wrong_options(cmd):
command = ["sanic", "fake.server.app", *cmd, "-p=9999", "--debug"]
out, err, exitcode = capture(command)
assert exitcode == 1
assert not out
lines = err.decode().split("\n")
assert "TLS certificates must be specified by either of:" in lines
errmsg = lines[8]
assert errmsg == "TLS certificates must be specified by either of:"
@pytest.mark.parametrize(
@@ -158,14 +114,14 @@ def test_tls_wrong_options(cmd: Tuple[str]):
("-H", "localhost", "-p", "9999"),
),
)
def test_host_port_localhost(cmd: Tuple[str]):
def test_host_port_localhost(cmd):
command = ["sanic", "fake.server.app", *cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
expected = b"Goin' Fast @ http://localhost:9999"
firstline = lines[starting_line(lines) + 1]
assert exitcode != 1
assert expected in lines, f"Lines found: {lines}\nErr output: {err}"
assert firstline == b"Goin' Fast @ http://localhost:9999"
@pytest.mark.parametrize(
@@ -175,14 +131,14 @@ def test_host_port_localhost(cmd: Tuple[str]):
("-H", "127.0.0.127", "-p", "9999"),
),
)
def test_host_port_ipv4(cmd: Tuple[str]):
def test_host_port_ipv4(cmd):
command = ["sanic", "fake.server.app", *cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
expected = b"Goin' Fast @ http://127.0.0.127:9999"
firstline = lines[starting_line(lines) + 1]
assert exitcode != 1
assert expected in lines, f"Lines found: {lines}\nErr output: {err}"
assert firstline == b"Goin' Fast @ http://127.0.0.127:9999"
@pytest.mark.parametrize(
@@ -192,14 +148,14 @@ def test_host_port_ipv4(cmd: Tuple[str]):
("-H", "::", "-p", "9999"),
),
)
def test_host_port_ipv6_any(cmd: Tuple[str]):
def test_host_port_ipv6_any(cmd):
command = ["sanic", "fake.server.app", *cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
expected = b"Goin' Fast @ http://[::]:9999"
firstline = lines[starting_line(lines) + 1]
assert exitcode != 1
assert expected in lines, f"Lines found: {lines}\nErr output: {err}"
assert firstline == b"Goin' Fast @ http://[::]:9999"
@pytest.mark.parametrize(
@@ -209,14 +165,14 @@ def test_host_port_ipv6_any(cmd: Tuple[str]):
("-H", "::1", "-p", "9999"),
),
)
def test_host_port_ipv6_loopback(cmd: Tuple[str]):
def test_host_port_ipv6_loopback(cmd):
command = ["sanic", "fake.server.app", *cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
expected = b"Goin' Fast @ http://[::1]:9999"
firstline = lines[starting_line(lines) + 1]
assert exitcode != 1
assert expected in lines, f"Lines found: {lines}\nErr output: {err}"
assert firstline == b"Goin' Fast @ http://[::1]:9999"
@pytest.mark.parametrize(
@@ -230,77 +186,56 @@ def test_host_port_ipv6_loopback(cmd: Tuple[str]):
(4, ("-w", "4")),
),
)
def test_num_workers(num: int, cmd: Tuple[str]):
def test_num_workers(num, cmd):
command = ["sanic", "fake.server.app", *cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
if num == 1:
expected = b"mode: production, single worker"
else:
expected = (f"mode: production, w/ {num} workers").encode()
worker_lines = [
line
for line in lines
if b"Starting worker" in line or b"Stopping worker" in line
]
assert exitcode != 1
assert expected in lines, f"Expected {expected}\nLines found: {lines}"
assert len(worker_lines) == num * 2, f"Lines found: {lines}"
@pytest.mark.parametrize("cmd", ("--debug",))
def test_debug(cmd: str):
@pytest.mark.parametrize("cmd", ("--debug", "-d"))
def test_debug(cmd):
command = ["sanic", "fake.server.app", cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
info = read_app_info(lines)
assert info["debug"] is True, f"Lines found: {lines}\nErr output: {err}"
assert (
info["auto_reload"] is False
), f"Lines found: {lines}\nErr output: {err}"
assert "dev" not in info, f"Lines found: {lines}\nErr output: {err}"
@pytest.mark.parametrize("cmd", ("--dev", "-d"))
def test_dev(cmd: str):
command = ["sanic", "fake.server.app", cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
info = read_app_info(lines)
assert info["debug"] is True, f"Lines found: {lines}\nErr output: {err}"
assert (
info["auto_reload"] is True
), f"Lines found: {lines}\nErr output: {err}"
assert info["debug"] is True
assert info["auto_reload"] is True
@pytest.mark.parametrize("cmd", ("--auto-reload", "-r"))
def test_auto_reload(cmd: str):
def test_auto_reload(cmd):
command = ["sanic", "fake.server.app", cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
info = read_app_info(lines)
assert info["debug"] is False, f"Lines found: {lines}\nErr output: {err}"
assert (
info["auto_reload"] is True
), f"Lines found: {lines}\nErr output: {err}"
assert "dev" not in info, f"Lines found: {lines}\nErr output: {err}"
assert info["debug"] is False
assert info["auto_reload"] is True
@pytest.mark.parametrize(
"cmd,expected", (("--access-log", True), ("--no-access-log", False))
)
def test_access_logs(cmd: str, expected: bool):
def test_access_logs(cmd, expected):
command = ["sanic", "fake.server.app", cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
info = read_app_info(lines)
assert (
info["access_log"] is expected
), f"Lines found: {lines}\nErr output: {err}"
assert info["access_log"] is expected
@pytest.mark.parametrize("cmd", ("--version", "-v"))
def test_version(cmd: str):
def test_version(cmd):
command = ["sanic", cmd]
out, err, exitcode = capture(command)
version_string = f"Sanic {__version__}; Routing {__routing_version__}\n"
@@ -315,12 +250,10 @@ def test_version(cmd: str):
("--no-noisy-exceptions", False),
),
)
def test_noisy_exceptions(cmd: str, expected: bool):
def test_noisy_exceptions(cmd, expected):
command = ["sanic", "fake.server.app", cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
info = read_app_info(lines)
assert (
info["noisy_exceptions"] is expected
), f"Lines found: {lines}\nErr output: {err}"
assert info["noisy_exceptions"] is expected

View File

@@ -1,5 +1,4 @@
import logging
import os
from contextlib import contextmanager
from os import environ
@@ -10,11 +9,8 @@ from unittest.mock import Mock, call
import pytest
from pytest import MonkeyPatch
from sanic import Sanic
from sanic.config import DEFAULT_CONFIG, Config
from sanic.constants import LocalCertCreator
from sanic.exceptions import PyFileError
@@ -43,21 +39,21 @@ class UltimateAnswer:
self.answer = int(answer)
def test_load_from_object(app: Sanic):
def test_load_from_object(app):
app.config.load(ConfigTest)
assert "CONFIG_VALUE" in app.config
assert app.config.CONFIG_VALUE == "should be used"
assert "not_for_config" not in app.config
def test_load_from_object_string(app: Sanic):
app.config.load("tests.test_config.ConfigTest")
def test_load_from_object_string(app):
app.config.load("test_config.ConfigTest")
assert "CONFIG_VALUE" in app.config
assert app.config.CONFIG_VALUE == "should be used"
assert "not_for_config" not in app.config
def test_load_from_instance(app: Sanic):
def test_load_from_instance(app):
app.config.load(ConfigTest())
assert "CONFIG_VALUE" in app.config
assert app.config.CONFIG_VALUE == "should be used"
@@ -66,21 +62,21 @@ def test_load_from_instance(app: Sanic):
assert "another_not_for_config" not in app.config
def test_load_from_object_string_exception(app: Sanic):
def test_load_from_object_string_exception(app):
with pytest.raises(ImportError):
app.config.load("test_config.Config.test")
def test_auto_env_prefix():
environ["SANIC_TEST_ANSWER"] = "42"
app = Sanic(name="Test")
app = Sanic(name=__name__)
assert app.config.TEST_ANSWER == 42
del environ["SANIC_TEST_ANSWER"]
def test_auto_bool_env_prefix():
environ["SANIC_TEST_ANSWER"] = "True"
app = Sanic(name="Test")
app = Sanic(name=__name__)
assert app.config.TEST_ANSWER is True
del environ["SANIC_TEST_ANSWER"]
@@ -88,28 +84,28 @@ def test_auto_bool_env_prefix():
@pytest.mark.parametrize("env_prefix", [None, ""])
def test_empty_load_env_prefix(env_prefix):
environ["SANIC_TEST_ANSWER"] = "42"
app = Sanic(name="Test", env_prefix=env_prefix)
app = Sanic(name=__name__, env_prefix=env_prefix)
assert getattr(app.config, "TEST_ANSWER", None) is None
del environ["SANIC_TEST_ANSWER"]
def test_env_prefix():
environ["MYAPP_TEST_ANSWER"] = "42"
app = Sanic(name="Test", env_prefix="MYAPP_")
app = Sanic(name=__name__, env_prefix="MYAPP_")
assert app.config.TEST_ANSWER == 42
del environ["MYAPP_TEST_ANSWER"]
def test_env_prefix_float_values():
environ["MYAPP_TEST_ROI"] = "2.3"
app = Sanic(name="Test", env_prefix="MYAPP_")
app = Sanic(name=__name__, env_prefix="MYAPP_")
assert app.config.TEST_ROI == 2.3
del environ["MYAPP_TEST_ROI"]
def test_env_prefix_string_value():
environ["MYAPP_TEST_TOKEN"] = "somerandomtesttoken"
app = Sanic(name="Test", env_prefix="MYAPP_")
app = Sanic(name=__name__, env_prefix="MYAPP_")
assert app.config.TEST_TOKEN == "somerandomtesttoken"
del environ["MYAPP_TEST_TOKEN"]
@@ -118,24 +114,12 @@ def test_env_w_custom_converter():
environ["SANIC_TEST_ANSWER"] = "42"
config = Config(converters=[UltimateAnswer])
app = Sanic(name="Test", config=config)
app = Sanic(name=__name__, config=config)
assert isinstance(app.config.TEST_ANSWER, UltimateAnswer)
assert app.config.TEST_ANSWER.answer == 42
del environ["SANIC_TEST_ANSWER"]
def test_env_lowercase():
with pytest.warns(None) as record:
environ["SANIC_test_answer"] = "42"
app = Sanic(name="Test")
assert app.config.test_answer == 42
assert str(record[0].message) == (
"[DEPRECATION v22.9] Lowercase environment variables will not be "
"loaded into Sanic config beginning in v22.9."
)
del environ["SANIC_test_answer"]
def test_add_converter_multiple_times(caplog):
def converter():
...
@@ -152,7 +136,7 @@ def test_add_converter_multiple_times(caplog):
assert len(config._converters) == 5
def test_load_from_file(app: Sanic):
def test_load_from_file(app):
config = dedent(
"""
VALUE = 'some value'
@@ -171,12 +155,12 @@ def test_load_from_file(app: Sanic):
assert "condition" not in app.config
def test_load_from_missing_file(app: Sanic):
def test_load_from_missing_file(app):
with pytest.raises(IOError):
app.config.load("non-existent file")
def test_load_from_envvar(app: Sanic):
def test_load_from_envvar(app):
config = "VALUE = 'some value'"
with temp_path() as config_path:
config_path.write_text(config)
@@ -186,7 +170,7 @@ def test_load_from_envvar(app: Sanic):
assert app.config.VALUE == "some value"
def test_load_from_missing_envvar(app: Sanic):
def test_load_from_missing_envvar(app):
with pytest.raises(IOError) as e:
app.config.load("non-existent variable")
assert str(e.value) == (
@@ -196,7 +180,7 @@ def test_load_from_missing_envvar(app: Sanic):
)
def test_load_config_from_file_invalid_syntax(app: Sanic):
def test_load_config_from_file_invalid_syntax(app):
config = "VALUE = some value"
with temp_path() as config_path:
config_path.write_text(config)
@@ -205,7 +189,7 @@ def test_load_config_from_file_invalid_syntax(app: Sanic):
app.config.load(config_path)
def test_overwrite_exisiting_config(app: Sanic):
def test_overwrite_exisiting_config(app):
app.config.DEFAULT = 1
class Config:
@@ -215,7 +199,7 @@ def test_overwrite_exisiting_config(app: Sanic):
assert app.config.DEFAULT == 2
def test_overwrite_exisiting_config_ignore_lowercase(app: Sanic):
def test_overwrite_exisiting_config_ignore_lowercase(app):
app.config.default = 1
class Config:
@@ -225,7 +209,7 @@ def test_overwrite_exisiting_config_ignore_lowercase(app: Sanic):
assert app.config.default == 1
def test_missing_config(app: Sanic):
def test_missing_config(app):
with pytest.raises(AttributeError, match="Config has no 'NON_EXISTENT'"):
_ = app.config.NON_EXISTENT
@@ -293,7 +277,7 @@ def test_config_custom_defaults_with_env():
del environ[key]
def test_config_access_log_passing_in_run(app: Sanic):
def test_config_access_log_passing_in_run(app):
assert app.config.ACCESS_LOG is True
@app.listener("after_server_start")
@@ -303,15 +287,12 @@ def test_config_access_log_passing_in_run(app: Sanic):
app.run(port=1340, access_log=False)
assert app.config.ACCESS_LOG is False
app.router.reset()
app.signal_router.reset()
app.run(port=1340, access_log=True)
assert app.config.ACCESS_LOG is True
@pytest.mark.asyncio
async def test_config_access_log_passing_in_create_server(app: Sanic):
async def test_config_access_log_passing_in_create_server(app):
assert app.config.ACCESS_LOG is True
@app.listener("after_server_start")
@@ -360,18 +341,27 @@ _test_setting_as_module = str(
],
ids=["from_dict", "from_class", "from_file"],
)
def test_update(app: Sanic, conf_object):
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: Sanic):
def test_update_from_lowercase_key(app):
d = {"test_setting_value": 1}
app.update_config(d)
assert "test_setting_value" not in app.config
def test_config_set_methods(app: Sanic, monkeypatch: MonkeyPatch):
def test_deprecation_notice_when_setting_logo(app):
message = (
"Setting the config.LOGO is deprecated and will no longer be "
"supported starting in v22.6."
)
with pytest.warns(DeprecationWarning, match=message):
app.config.LOGO = "My Custom Logo"
def test_config_set_methods(app, monkeypatch):
post_set = Mock()
monkeypatch.setattr(Config, "_post_set", post_set)
@@ -416,33 +406,3 @@ def test_config_set_methods(app: Sanic, monkeypatch: MonkeyPatch):
app.config.update_config({"FOO": 10})
post_set.assert_called_once_with("FOO", 10)
def test_negative_proxy_count(app: Sanic):
app.config.PROXIES_COUNT = -1
message = (
"PROXIES_COUNT cannot be negative. "
"https://sanic.readthedocs.io/en/latest/sanic/config.html"
"#proxy-configuration"
)
with pytest.raises(ValueError, match=message):
app.prepare()
@pytest.mark.parametrize(
"passed,expected",
(
("auto", LocalCertCreator.AUTO),
("mkcert", LocalCertCreator.MKCERT),
("trustme", LocalCertCreator.TRUSTME),
("AUTO", LocalCertCreator.AUTO),
("MKCERT", LocalCertCreator.MKCERT),
("TRUSTME", LocalCertCreator.TRUSTME),
),
)
def test_convert_local_cert_creator(passed, expected):
os.environ["SANIC_LOCAL_CERT_CREATOR"] = passed
app = Sanic("Test")
assert app.config.LOCAL_CERT_CREATOR is expected
del os.environ["SANIC_LOCAL_CERT_CREATOR"]

View File

@@ -3,7 +3,6 @@ from http.cookies import SimpleCookie
import pytest
from sanic import Sanic
from sanic.cookies import Cookie
from sanic.response import text
@@ -222,29 +221,30 @@ def test_cookie_bad_max_age(app, max_age):
assert response.status == 500
@pytest.mark.parametrize("expires", [timedelta(seconds=60)])
def test_cookie_expires(app: Sanic, expires: timedelta):
expires_time = datetime.utcnow().replace(microsecond=0) + expires
@pytest.mark.parametrize(
"expires", [datetime.utcnow() + timedelta(seconds=60)]
)
def test_cookie_expires(app, expires):
expires = expires.replace(microsecond=0)
cookies = {"test": "wait"}
@app.get("/")
def handler(request):
response = text("pass")
response.cookies["test"] = "pass"
response.cookies["test"]["expires"] = expires_time
response.cookies["test"]["expires"] = expires
return response
request, response = app.test_client.get(
"/", cookies=cookies, raw_cookies=True
)
cookie_expires = datetime.utcfromtimestamp(
response.raw_cookies["test"].expires
).replace(microsecond=0)
assert response.status == 200
assert response.cookies["test"] == "pass"
assert cookie_expires == expires_time
assert cookie_expires == expires
@pytest.mark.parametrize("expires", ["Fri, 21-Dec-2018 15:30:00 GMT"])

View File

@@ -17,7 +17,7 @@ class CustomRequest(Request):
def test_custom_request():
app = Sanic(name="Test", request_class=CustomRequest)
app = Sanic(name=__name__, request_class=CustomRequest)
@app.route("/post", methods=["POST"])
async def post_handler(request):

View File

@@ -67,7 +67,7 @@ def test_auto_fallback_with_data(app):
_, response = app.test_client.get("/error")
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"
assert response.content_type == "text/html; charset=utf-8"
_, response = app.test_client.post("/error", json={"foo": "bar"})
assert response.status == 500
@@ -75,7 +75,7 @@ def test_auto_fallback_with_data(app):
_, response = app.test_client.post("/error", data={"foo": "bar"})
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"
assert response.content_type == "text/html; charset=utf-8"
def test_auto_fallback_with_content_type(app):
@@ -91,7 +91,7 @@ def test_auto_fallback_with_content_type(app):
"/error", headers={"content-type": "foo/bar", "accept": "*/*"}
)
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"
assert response.content_type == "text/html; charset=utf-8"
def test_route_error_format_set_on_auto(app):
@@ -174,17 +174,6 @@ def test_route_error_format_unknown(app):
...
def test_fallback_with_content_type_html(app):
app.config.FALLBACK_ERROR_FORMAT = "auto"
_, response = app.test_client.get(
"/error",
headers={"content-type": "application/json", "accept": "text/html"},
)
assert response.status == 500
assert response.content_type == "text/html; charset=utf-8"
def test_fallback_with_content_type_mismatch_accept(app):
app.config.FALLBACK_ERROR_FORMAT = "auto"
@@ -197,10 +186,10 @@ def test_fallback_with_content_type_mismatch_accept(app):
_, response = app.test_client.get(
"/error",
headers={"content-type": "text/html", "accept": "foo/bar"},
headers={"content-type": "text/plain", "accept": "foo/bar"},
)
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"
assert response.content_type == "text/html; charset=utf-8"
app.router.reset()
@@ -219,7 +208,7 @@ def test_fallback_with_content_type_mismatch_accept(app):
headers={"accept": "foo/bar"},
)
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"
assert response.content_type == "text/html; charset=utf-8"
_, response = app.test_client.get(
"/alt1",
headers={"accept": "foo/bar,*/*"},
@@ -232,7 +221,7 @@ def test_fallback_with_content_type_mismatch_accept(app):
headers={"accept": "foo/bar"},
)
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"
assert response.content_type == "text/html; charset=utf-8"
_, response = app.test_client.get(
"/alt2",
headers={"accept": "foo/bar,*/*"},
@@ -245,13 +234,6 @@ def test_fallback_with_content_type_mismatch_accept(app):
headers={"accept": "foo/bar"},
)
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"
_, response = app.test_client.get(
"/alt3",
headers={"accept": "foo/bar,text/html"},
)
assert response.status == 500
assert response.content_type == "text/html; charset=utf-8"
@@ -306,10 +288,6 @@ def test_allow_fallback_error_format_set_main_process_start(app):
def test_setting_fallback_on_config_changes_as_expected(app):
app.error_handler = ErrorHandler()
_, response = app.test_client.get("/error")
assert response.content_type == "text/plain; charset=utf-8"
app.config.FALLBACK_ERROR_FORMAT = "html"
_, response = app.test_client.get("/error")
assert response.content_type == "text/html; charset=utf-8"

View File

@@ -1,4 +1,5 @@
import logging
import warnings
import pytest
@@ -6,16 +7,9 @@ from bs4 import BeautifulSoup
from sanic import Sanic
from sanic.exceptions import (
BadRequest,
ContentRangeError,
ExpectationFailed,
Forbidden,
HeaderExpectationFailed,
InvalidUsage,
MethodNotAllowed,
MethodNotSupported,
NotFound,
RangeNotSatisfiable,
SanicException,
ServerError,
Unauthorized,
@@ -40,7 +34,6 @@ class SanicExceptionTestException(Exception):
@pytest.fixture(scope="module")
def exception_app():
app = Sanic("test_exceptions")
app.config.FALLBACK_ERROR_FORMAT = "html"
@app.route("/")
def handler(request):
@@ -84,7 +77,7 @@ def exception_app():
@app.route("/invalid")
def handler_invalid(request):
raise BadRequest("OK")
raise InvalidUsage("OK")
@app.route("/abort/401")
def handler_401_error(request):
@@ -143,7 +136,7 @@ def test_server_error_exception(exception_app):
def test_invalid_usage_exception(exception_app):
"""Test the built-in BadRequest exception works"""
"""Test the built-in InvalidUsage exception works"""
request, response = exception_app.test_client.get("/invalid")
assert response.status == 400
@@ -259,7 +252,7 @@ def test_custom_exception_default_message(exception_app):
def test_exception_in_ws_logged(caplog):
app = Sanic("Test")
app = Sanic(__name__)
@app.websocket("/feed")
async def feed(request, ws):
@@ -279,7 +272,7 @@ def test_exception_in_ws_logged(caplog):
@pytest.mark.parametrize("debug", (True, False))
def test_contextual_exception_context(debug):
app = Sanic("Test")
app = Sanic(__name__)
class TeapotError(SanicException):
status_code = 418
@@ -314,7 +307,7 @@ def test_contextual_exception_context(debug):
@pytest.mark.parametrize("debug", (True, False))
def test_contextual_exception_extra(debug):
app = Sanic("Test")
app = Sanic(__name__)
class TeapotError(SanicException):
status_code = 418
@@ -361,7 +354,7 @@ def test_contextual_exception_extra(debug):
@pytest.mark.parametrize("override", (True, False))
def test_contextual_exception_functional_message(override):
app = Sanic("Test")
app = Sanic(__name__)
class TeapotError(SanicException):
status_code = 418
@@ -382,10 +375,3 @@ def test_contextual_exception_functional_message(override):
assert response.status == 418
assert response.json["message"] == error_message
assert response.json["context"] == {"foo": "bar"}
def test_exception_aliases():
assert InvalidUsage is BadRequest
assert MethodNotSupported is MethodNotAllowed
assert ContentRangeError is RangeNotSatisfiable
assert HeaderExpectationFailed is ExpectationFailed

View File

@@ -10,10 +10,16 @@ from bs4 import BeautifulSoup
from pytest import LogCaptureFixture, MonkeyPatch
from sanic import Sanic, handlers
from sanic.exceptions import BadRequest, Forbidden, NotFound, ServerError
from sanic.exceptions import Forbidden, InvalidUsage, NotFound, ServerError
from sanic.handlers import ErrorHandler
from sanic.request import Request
from sanic.response import text
from sanic.response import stream, text
async def sample_streaming_fn(response):
await response.write("foo,")
await asyncio.sleep(0.001)
await response.write("bar")
class ErrorWithRequestCtx(ServerError):
@@ -26,7 +32,7 @@ def exception_handler_app():
@exception_handler_app.route("/1", error_format="html")
def handler_1(request):
raise BadRequest("OK")
raise InvalidUsage("OK")
@exception_handler_app.route("/2", error_format="html")
def handler_2(request):
@@ -75,10 +81,10 @@ def exception_handler_app():
@exception_handler_app.exception(Forbidden)
async def async_handler_exception(request, exception):
response = await request.respond(content_type="text/csv")
await response.send("foo,")
await asyncio.sleep(0.001)
await response.send("bar")
return stream(
sample_streaming_fn,
content_type="text/csv",
)
@exception_handler_app.middleware
async def some_request_middleware(request):
@@ -177,7 +183,7 @@ def test_exception_handler_lookup(exception_handler_app: Sanic):
class ModuleNotFoundError(ImportError):
pass
handler = ErrorHandler()
handler = ErrorHandler("auto")
handler.add(ImportError, import_error_handler)
handler.add(CustomError, custom_error_handler)
handler.add(ServerError, server_error_handler)
@@ -210,6 +216,31 @@ def test_exception_handler_processed_request_middleware(
assert response.text == "Done."
def test_single_arg_exception_handler_notice(
exception_handler_app: Sanic, caplog: LogCaptureFixture
):
class CustomErrorHandler(ErrorHandler):
def lookup(self, exception):
return super().lookup(exception, None)
exception_handler_app.error_handler = CustomErrorHandler()
message = (
"[DEPRECATION v22.3] You are using a deprecated error handler. The "
"lookup method should accept two positional parameters: (exception, "
"route_name: Optional[str]). Until you upgrade your "
"ErrorHandler.lookup, Blueprint specific exceptions will not work "
"properly. Beginning in v22.3, the legacy style lookup method will "
"not work at all."
)
with pytest.warns(DeprecationWarning) as record:
_, response = exception_handler_app.test_client.get("/1")
assert len(record) == 1
assert record[0].message.args[0] == message
assert response.status == 400
def test_error_handler_noisy_log(
exception_handler_app: Sanic, monkeypatch: MonkeyPatch
):
@@ -248,13 +279,14 @@ def test_exception_handler_response_was_sent(
@app.route("/2")
async def handler2(request: Request):
await request.respond()
response = await request.respond()
raise ServerError("Exception")
with caplog.at_level(logging.WARNING):
_, response = app.test_client.get("/1")
assert "some text" in response.text
# Change to assert warning not in the records in the future version.
message_in_records(
caplog.records,
(

Some files were not shown because too many files have changed in this diff Show More