Compare commits

...

13 Commits

Author SHA1 Message Date
Adam Hopkins
f3035abfae Add changed files to reload trigger listener 2023-08-23 09:31:27 +03:00
Adam Hopkins
16256522f6 Disable Test PyPI dist 2023-07-25 16:13:47 +03:00
Adam Hopkins
205795d1e8 Prepare for v23.6 release (#2797) 2023-07-25 15:57:29 +03:00
Adam Hopkins
9cbe1fb8ad Add convenience method for exception reporting (#2792) 2023-07-18 00:21:55 +03:00
L. Kärkkäinen
31d7ba8f8c Add request.client_ip (#2790)
Co-authored-by: L. Kärkkäinen <Tronic@users.noreply.github.com>
2023-07-13 23:01:02 +03:00
Adam Hopkins
dc3c4d1393 Add custom typing to config and ctx (#2785) 2023-07-12 23:47:58 +03:00
Adam Hopkins
929d270569 Update bug-report.yml (#2788) 2023-07-12 19:00:28 +03:00
Adam Hopkins
93714df051 Update bug-report.yml (#2787) 2023-07-12 18:53:14 +03:00
L. Kärkkäinen
6e61eab872 Increase KEEP_ALIVE_TIMEOUT default to 120 seconds (#2670)
Co-authored-by: L. Kärkkäinen <Tronic@users.noreply.github.com>
2023-07-12 08:45:30 +03:00
Adam Hopkins
6848ff24d8 Run keep alive tests in loop to get available port (#2779) 2023-07-09 22:58:17 +03:00
Adam Hopkins
666371bb92 Set multiprocessing start method early (#2776) 2023-07-09 22:34:15 +03:00
Adam Hopkins
4a2b82e42e Remove Python3.7 support (#2777) 2023-07-09 22:00:14 +03:00
Moshe Nahmias
5dd1623192 Alow Blueprint routes to explicitly define error_format (#2773)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-09 14:47:59 +03:00
49 changed files with 923 additions and 238 deletions

View File

@@ -21,7 +21,14 @@ body:
id: code
attributes:
label: Code snippet
description: Relevant source code, make sure to remove what is not necessary.
description: |
Relevant source code, make sure to remove what is not necessary. Please try and format your code so that it is easier to read. For example:
```python
from sanic import Sanic
app = Sanic("Example")
```
validations:
required: false
- type: textarea
@@ -42,11 +49,16 @@ body:
- ASGI
validations:
required: true
- type: input
- type: dropdown
id: os
attributes:
label: Operating System
description: What OS?
options:
- Linux
- MacOS
- Windows
- Other (tell us in the description)
validations:
required: true
- type: input

View File

@@ -17,7 +17,6 @@ jobs:
matrix:
os: [ubuntu-latest]
config:
- { python-version: 3.7, tox-env: security}
- { python-version: 3.8, tox-env: security}
- { python-version: 3.9, tox-env: security}
- { python-version: "3.10", tox-env: security}

View File

@@ -5,11 +5,11 @@ on:
tox-env:
description: "Tox Env to run on the PyPy Infra"
required: false
default: "pypy37"
default: "pypy310"
pypy-version:
description: "Version of PyPy to use"
required: false
default: "pypy-3.7"
default: "pypy-3.10"
jobs:
testPyPy:
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}

View File

@@ -1,36 +0,0 @@
name: Python 3.7 Tests
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
testPy37:
if: github.event.pull_request.draft == false
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
# os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest]
config:
- { python-version: 3.7, tox-env: py37 }
- { python-version: 3.7, tox-env: py37-no-ext }
steps:
- name: Checkout the Repository
uses: actions/checkout@v2
id: checkout-branch
- name: Run Unit Tests
uses: harshanarayana/custom-actions@main
with:
python-version: ${{ matrix.config.python-version }}
test-infra-tool: tox
test-infra-version: latest
action: tests
test-additional-args: "-e=${{ matrix.config.tox-env }}"
test-failure-retry: "3"

View File

@@ -17,7 +17,6 @@ jobs:
matrix:
os: [ubuntu-latest]
config:
# - { python-version: 3.7, tox-env: type-checking}
- { python-version: 3.8, tox-env: type-checking}
- { python-version: 3.9, tox-env: type-checking}
- { python-version: "3.10", tox-env: type-checking}

View File

@@ -16,12 +16,10 @@ jobs:
fail-fast: false
matrix:
config:
- { python-version: 3.7, tox-env: py37-no-ext }
- { python-version: 3.8, tox-env: py38-no-ext }
- { python-version: 3.9, tox-env: py39-no-ext }
- { python-version: "3.10", tox-env: py310-no-ext }
- { python-version: "3.11", tox-env: py310-no-ext }
- { python-version: pypy-3.7, tox-env: pypy37-no-ext }
steps:
- name: Checkout Repository

View File

@@ -14,7 +14,7 @@ jobs:
strategy:
fail-fast: true
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
python-version: ["3.8", "3.9", "3.10", "3.11"]
steps:
- name: Checkout repository

View File

@@ -1,28 +1,39 @@
name: Publish Artifacts
name: Upload Python Package
on:
release:
types: [created]
workflow_dispatch:
jobs:
publishPythonPackage:
name: Publishing Sanic Release Artifacts
build-n-publish:
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
python-version: ["3.10"]
steps:
- name: Checkout Repository
uses: actions/checkout@v2
- name: Publish Python Package
uses: harshanarayana/custom-actions@main
with:
python-version: ${{ matrix.python-version }}
package-infra-name: "twine"
pypi-user: __token__
pypi-access-token: ${{ secrets.PYPI_ACCESS_TOKEN }}
action: "package-publish"
pypi-verify-metadata: "true"
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Install pypa/build
run: >-
python3 -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: >-
python3 -m
build
--sdist
--wheel
--outdir dist/
.
# - name: Publish distribution 📦 to Test PyPI
# uses: pypa/gh-action-pypi-publish@release/v1
# with:
# password: ${{ secrets.SANIC_TEST_PYPI_API_TOKEN }}
# repository-url: https://test.pypi.org/legacy/
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.SANIC_PYPI_API_TOKEN }}

View File

@@ -5,6 +5,7 @@
| 🔷 In support release
|
.. mdinclude:: ./releases/23/23.6.md
.. mdinclude:: ./releases/23/23.3.md
.. mdinclude:: ./releases/22/22.12.md
.. mdinclude:: ./releases/22/22.9.md

View File

@@ -1,4 +1,4 @@
## Version 23.3.0 🔶
## Version 23.3.0
### Features
- [#2545](https://github.com/sanic-org/sanic/pull/2545) Standardize init of exceptions for more consistent control of HTTP responses using exceptions

View File

@@ -0,0 +1,33 @@
## Version 23.6.0 🔶
### Features
- [#2670](https://github.com/sanic-org/sanic/pull/2670) Increase `KEEP_ALIVE_TIMEOUT` default to 120 seconds
- [#2716](https://github.com/sanic-org/sanic/pull/2716) Adding allow route overwrite option in blueprint
- [#2724](https://github.com/sanic-org/sanic/pull/2724) and [#2792](https://github.com/sanic-org/sanic/pull/2792) Add a new exception signal for ALL exceptions raised anywhere in application
- [#2727](https://github.com/sanic-org/sanic/pull/2727) Add name prefixing to BP groups
- [#2754](https://github.com/sanic-org/sanic/pull/2754) Update request type on middleware types
- [#2770](https://github.com/sanic-org/sanic/pull/2770) Better exception message on startup time application induced import error
- [#2776](https://github.com/sanic-org/sanic/pull/2776) Set multiprocessing start method early
- [#2785](https://github.com/sanic-org/sanic/pull/2785) Add custom typing to config and ctx objects
- [#2790](https://github.com/sanic-org/sanic/pull/2790) Add `request.client_ip`
### Bugfixes
- [#2728](https://github.com/sanic-org/sanic/pull/2728) Fix traversals for intended results
- [#2729](https://github.com/sanic-org/sanic/pull/2729) Handle case when headers argument of ResponseStream constructor is None
- [#2737](https://github.com/sanic-org/sanic/pull/2737) Fix type annotation for `JSONREsponse` default content type
- [#2740](https://github.com/sanic-org/sanic/pull/2740) Use Sanic's serializer for JSON responses in the Inspector
- [#2760](https://github.com/sanic-org/sanic/pull/2760) Support for `Request.get_current` in ASGI mode
- [#2773](https://github.com/sanic-org/sanic/pull/2773) Alow Blueprint routes to explicitly define error_format
- [#2774](https://github.com/sanic-org/sanic/pull/2774) Resolve headers on different renderers
- [#2782](https://github.com/sanic-org/sanic/pull/2782) Resolve pypy compatibility issues
### Deprecations and Removals
- [#2777](https://github.com/sanic-org/sanic/pull/2777) Remove Python 3.7 support
### Developer infrastructure
- [#2766](https://github.com/sanic-org/sanic/pull/2766) Unpin setuptools version
- [#2779](https://github.com/sanic-org/sanic/pull/2779) Run keep alive tests in loop to get available port
### Improved Documentation
- [#2741](https://github.com/sanic-org/sanic/pull/2741) Better documentation examples about running Sanic
From that list, the items to highlight in the release notes:

View File

@@ -1,6 +1,11 @@
from types import SimpleNamespace
from typing_extensions import TypeAlias
from sanic.__version__ import __version__
from sanic.app import Sanic
from sanic.blueprints import Blueprint
from sanic.config import Config
from sanic.constants import HTTPMethod
from sanic.exceptions import (
BadRequest,
@@ -32,15 +37,29 @@ from sanic.response import (
from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket
DefaultSanic: TypeAlias = "Sanic[Config, SimpleNamespace]"
"""
A type alias for a Sanic app with a default config and namespace.
"""
DefaultRequest: TypeAlias = Request[DefaultSanic, SimpleNamespace]
"""
A type alias for a request with a default Sanic app and namespace.
"""
__all__ = (
"__version__",
# Common objects
"Sanic",
"Config",
"Blueprint",
"HTTPMethod",
"HTTPResponse",
"Request",
"Websocket",
# Common types
"DefaultSanic",
"DefaultRequest",
# Common exceptions
"BadRequest",
"ExpectationFailed",

View File

@@ -1 +1 @@
__version__ = "23.3.1"
__version__ = "23.6.0"

View File

@@ -17,7 +17,7 @@ from asyncio import (
from asyncio.futures import Future
from collections import defaultdict, deque
from contextlib import contextmanager, suppress
from functools import partial
from functools import partial, wraps
from inspect import isawaitable
from os import environ
from socket import socket
@@ -29,9 +29,11 @@ from typing import (
AnyStr,
Awaitable,
Callable,
ClassVar,
Coroutine,
Deque,
Dict,
Generic,
Iterable,
Iterator,
List,
@@ -41,6 +43,8 @@ from typing import (
Type,
TypeVar,
Union,
cast,
overload,
)
from urllib.parse import urlencode, urlunparse
@@ -83,7 +87,7 @@ from sanic.request import Request
from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream
from sanic.router import Router
from sanic.server.websockets.impl import ConnectionClosed
from sanic.signals import Signal, SignalRouter
from sanic.signals import Event, Signal, SignalRouter
from sanic.touchup import TouchUp, TouchUpMeta
from sanic.types.shared_ctx import SharedContext
from sanic.worker.inspector import Inspector
@@ -102,8 +106,17 @@ if TYPE_CHECKING:
if OS_IS_WINDOWS: # no cov
enable_windows_color_support()
ctx_type = TypeVar("ctx_type")
config_type = TypeVar("config_type", bound=Config)
class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
class Sanic(
Generic[config_type, ctx_type],
StaticHandleMixin,
BaseSanic,
StartupMixin,
metaclass=TouchUpMeta,
):
"""
The main application instance
"""
@@ -158,14 +171,102 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"websocket_tasks",
)
_app_registry: Dict[str, "Sanic"] = {}
test_mode = False
_app_registry: ClassVar[Dict[str, "Sanic"]] = {}
test_mode: ClassVar[bool] = False
@overload
def __init__(
self: Sanic[Config, SimpleNamespace],
name: str,
config: None = None,
ctx: None = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
env_prefix: Optional[str] = SANIC_PREFIX,
request_class: Optional[Type[Request]] = None,
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
configure_logging: bool = True,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None:
...
@overload
def __init__(
self: Sanic[config_type, SimpleNamespace],
name: str,
config: Optional[config_type] = None,
ctx: None = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
env_prefix: Optional[str] = SANIC_PREFIX,
request_class: Optional[Type[Request]] = None,
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
configure_logging: bool = True,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None:
...
@overload
def __init__(
self: Sanic[Config, ctx_type],
name: str,
config: None = None,
ctx: Optional[ctx_type] = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
env_prefix: Optional[str] = SANIC_PREFIX,
request_class: Optional[Type[Request]] = None,
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
configure_logging: bool = True,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None:
...
@overload
def __init__(
self: Sanic[config_type, ctx_type],
name: str,
config: Optional[config_type] = None,
ctx: Optional[ctx_type] = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
env_prefix: Optional[str] = SANIC_PREFIX,
request_class: Optional[Type[Request]] = None,
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
configure_logging: bool = True,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None:
...
def __init__(
self,
name: Optional[str] = None,
config: Optional[Config] = None,
ctx: Optional[Any] = None,
name: str,
config: Optional[config_type] = None,
ctx: Optional[ctx_type] = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
@@ -193,7 +294,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
)
# First setup config
self.config: Config = config or Config(env_prefix=env_prefix)
self.config: config_type = cast(
config_type, config or Config(env_prefix=env_prefix)
)
if inspector:
self.config.INSPECTOR = inspector
@@ -217,7 +320,7 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
certloader_class or CertLoader
)
self.configure_logging: bool = configure_logging
self.ctx: Any = ctx or SimpleNamespace()
self.ctx: ctx_type = cast(ctx_type, ctx or SimpleNamespace())
self.error_handler: ErrorHandler = error_handler or ErrorHandler()
self.inspector_class: Type[Inspector] = inspector_class or Inspector
self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list)
@@ -502,6 +605,19 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
raise NotFound("Could not find signal %s" % event)
return await wait_for(signal.ctx.event.wait(), timeout=timeout)
def report_exception(
self, handler: Callable[[Sanic, Exception], Coroutine[Any, Any, None]]
):
@wraps(handler)
async def report(exception: Exception) -> None:
await handler(self, exception)
self.add_signal(
handler=report, event=Event.SERVER_EXCEPTION_REPORT.value
)
return report
def enable_websocket(self, enable=True):
"""Enable or disable the support for websocket.
@@ -773,10 +889,12 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
:raises ServerError: response 500
"""
response = None
await self.dispatch(
"server.lifecycle.exception",
context={"exception": exception},
)
if not getattr(exception, "__dispatched__", False):
... # DO NOT REMOVE THIS LINE. IT IS NEEDED FOR TOUCHUP.
await self.dispatch(
"server.exception.report",
context={"exception": exception},
)
await self.dispatch(
"http.lifecycle.exception",
inline=True,
@@ -1207,13 +1325,28 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
app,
loop,
):
if callable(task):
async def do(task):
try:
task = task(app)
except TypeError:
task = task()
if callable(task):
try:
task = task(app)
except TypeError:
task = task()
if isawaitable(task):
await task
except CancelledError:
error_logger.warning(
f"Task {task} was cancelled before it completed."
)
raise
except Exception as e:
await app.dispatch(
"server.exception.report",
context={"exception": e},
)
raise
return task
return do(task)
@classmethod
def _loop_add_task(
@@ -1227,18 +1360,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, 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 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
else:
task = loop.create_task(prepped, name=name)
task = loop.create_task(prepped, name=name)
if name and register and sys.version_info > (3, 7):
if name and register:
app._task_registry[name] = task
return task

View File

@@ -111,7 +111,7 @@ class Blueprint(BaseSanic):
def __init__(
self,
name: str = None,
name: str,
url_prefix: Optional[str] = None,
host: Optional[Union[List[str], str]] = None,
version: Optional[Union[int, str, float]] = None,
@@ -319,6 +319,10 @@ class Blueprint(BaseSanic):
# Prepend the blueprint URI prefix if available
uri = self._setup_uri(future.uri, url_prefix)
route_error_format = (
future.error_format if future.error_format else error_format
)
version_prefix = self.version_prefix
for prefix in (
future.version_prefix,
@@ -358,7 +362,7 @@ class Blueprint(BaseSanic):
future.unquote,
future.static,
version_prefix,
error_format,
route_error_format,
future.route_context,
)

View File

@@ -43,14 +43,14 @@ DEFAULT_CONFIG = {
"DEPRECATION_FILTER": "once",
"FORWARDED_FOR_HEADER": "X-Forwarded-For",
"FORWARDED_SECRET": None,
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0,
"INSPECTOR": False,
"INSPECTOR_HOST": "localhost",
"INSPECTOR_PORT": 6457,
"INSPECTOR_TLS_KEY": _default,
"INSPECTOR_TLS_CERT": _default,
"INSPECTOR_API_KEY": "",
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
"KEEP_ALIVE_TIMEOUT": 120,
"KEEP_ALIVE": True,
"LOCAL_CERT_CREATOR": LocalCertCreator.AUTO,
"LOCAL_TLS_KEY": _default,
@@ -61,16 +61,16 @@ DEFAULT_CONFIG = {
"NOISY_EXCEPTIONS": False,
"PROXIES_COUNT": None,
"REAL_IP_HEADER": None,
"REQUEST_BUFFER_SIZE": 65536, # 64 KiB
"REQUEST_MAX_HEADER_SIZE": 8192, # 8 KiB, but cannot exceed 16384
"REQUEST_BUFFER_SIZE": 65536,
"REQUEST_MAX_HEADER_SIZE": 8192, # 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
"REQUEST_MAX_SIZE": 100_000_000,
"REQUEST_TIMEOUT": 60,
"RESPONSE_TIMEOUT": 60,
"TLS_CERT_PASSWORD": "",
"TOUCHUP": _default,
"USE_UVLOOP": _default,
"WEBSOCKET_MAX_SIZE": 2**20, # 1 megabyte
"WEBSOCKET_MAX_SIZE": 2**20, # 1 MiB
"WEBSOCKET_PING_INTERVAL": 20,
"WEBSOCKET_PING_TIMEOUT": 20,
}

View File

@@ -312,7 +312,7 @@ def exception_response(
debug: bool,
fallback: str,
base: t.Type[BaseRenderer],
renderer: t.Type[t.Optional[BaseRenderer]] = None,
renderer: t.Optional[t.Type[BaseRenderer]] = None,
) -> HTTPResponse:
"""
Render a response for the default FALLBACK exception handler.

View File

@@ -90,7 +90,7 @@ class SanicException(Exception):
super().__init__(message)
self.status_code = status_code
self.status_code = status_code or self.status_code
self.quiet = quiet
self.headers = headers

View File

@@ -436,7 +436,7 @@ def format_http1_response(status: int, headers: HeaderBytesIterable) -> bytes:
def parse_credentials(
header: Optional[str],
prefixes: Union[List, Tuple, Set] = None,
prefixes: Optional[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)):

View File

@@ -38,3 +38,15 @@ class ExceptionMixin(metaclass=SanicMeta):
return handler
return decorator
def all_exceptions(self, handler):
"""
This method enables the process of creating a global exception
handler for the current blueprint under question.
:param handler: A coroutine function to handle exceptions
:return a decorated method to handle global exceptions for any
route registered under this blueprint.
"""
return self.exception(Exception)(handler)

View File

@@ -4,7 +4,7 @@ from typing import Any, Callable, Dict, Optional, Set, Union
from sanic.base.meta import SanicMeta
from sanic.models.futures import FutureSignal
from sanic.models.handler_types import SignalHandler
from sanic.signals import Signal
from sanic.signals import Event, Signal
from sanic.types import HashableDict
@@ -80,3 +80,9 @@ class SignalMixin(metaclass=SanicMeta):
def event(self, event: str):
raise NotImplementedError
def catch_exception(self, handler):
async def signal_handler(exception: Exception):
await handler(self, exception)
self.signal(Event.SERVER_LIFECYCLE_EXCEPTION)(signal_handler)

View File

@@ -16,7 +16,13 @@ from asyncio import (
from contextlib import suppress
from functools import partial
from importlib import import_module
from multiprocessing import Manager, Pipe, get_context
from multiprocessing import (
Manager,
Pipe,
get_context,
get_start_method,
set_start_method,
)
from multiprocessing.context import BaseContext
from pathlib import Path
from socket import SHUT_RDWR, socket
@@ -25,6 +31,7 @@ from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Dict,
List,
Mapping,
@@ -81,13 +88,17 @@ else: # no cov
class StartupMixin(metaclass=SanicMeta):
_app_registry: Dict[str, Sanic]
_app_registry: ClassVar[Dict[str, Sanic]]
config: Config
listeners: Dict[str, List[ListenerType[Any]]]
state: ApplicationState
websocket_enabled: bool
multiplexer: WorkerMultiplexer
start_method: StartMethod = _default
test_mode: ClassVar[bool]
start_method: ClassVar[StartMethod] = _default
START_METHOD_SET: ClassVar[bool] = False
def setup_loop(self):
if not self.asgi:
@@ -691,11 +702,26 @@ class StartupMixin(metaclass=SanicMeta):
else "spawn"
)
@classmethod
def _set_startup_method(cls) -> None:
if cls.START_METHOD_SET and not cls.test_mode:
return
method = cls._get_startup_method()
set_start_method(method, force=cls.test_mode)
cls.START_METHOD_SET = True
@classmethod
def _get_context(cls) -> BaseContext:
method = cls._get_startup_method()
logger.debug("Creating multiprocessing context using '%s'", method)
return get_context(method)
actual = get_start_method()
if method != actual:
raise RuntimeError(
f"Start method '{method}' was requested, but '{actual}' "
"was actually set."
)
return get_context()
@classmethod
def serve(
@@ -705,6 +731,7 @@ class StartupMixin(metaclass=SanicMeta):
app_loader: Optional[AppLoader] = None,
factory: Optional[Callable[[], Sanic]] = None,
) -> None:
cls._set_startup_method()
os.environ["SANIC_MOTD_OUTPUT"] = "true"
apps = list(cls._app_registry.values())
if factory:

View File

@@ -1,5 +1,4 @@
import asyncio
import sys
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union
@@ -16,20 +15,10 @@ ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
class MockProtocol: # no cov
def __init__(self, transport: "MockTransport", loop):
# This should be refactored when < 3.8 support is dropped
self.transport = transport
# Fixup for 3.8+; Sanic still supports 3.7 where loop is required
loop = loop if sys.version_info[:2] < (3, 8) else None
# Optional in 3.9, necessary in 3.10 because the parameter "loop"
# was completely removed
if not loop:
self._not_paused = asyncio.Event()
self._not_paused.set()
self._complete = asyncio.Event()
else:
self._not_paused = asyncio.Event(loop=loop)
self._not_paused.set()
self._complete = asyncio.Event(loop=loop)
self._not_paused = asyncio.Event()
self._not_paused.set()
self._complete = asyncio.Event()
def pause_writing(self) -> None:
self._not_paused.clear()

View File

@@ -2,11 +2,13 @@ from __future__ import annotations
from contextvars import ContextVar
from inspect import isawaitable
from types import SimpleNamespace
from typing import (
TYPE_CHECKING,
Any,
DefaultDict,
Dict,
Generic,
List,
Optional,
Tuple,
@@ -15,6 +17,7 @@ from typing import (
)
from sanic_routing.route import Route
from typing_extensions import TypeVar
from sanic.http.constants import HTTP # type: ignore
from sanic.http.stream import Stream
@@ -23,13 +26,13 @@ from sanic.models.http_types import Credentials
if TYPE_CHECKING:
from sanic.server import ConnInfo
from sanic.app import Sanic
from sanic.config import Config
from sanic.server import ConnInfo
import uuid
from collections import defaultdict
from types import SimpleNamespace
from urllib.parse import parse_qs, parse_qsl, urlunparse
from httptools import parse_url
@@ -68,8 +71,21 @@ try:
except ImportError:
from json import loads as json_loads # type: ignore
if TYPE_CHECKING:
# The default argument of TypeVar is proposed to be added in Python 3.13
# by PEP 696 (https://www.python.org/dev/peps/pep-0696/).
# Therefore, we use typing_extensions.TypeVar for compatibility.
# For more information, see:
# https://discuss.python.org/t/pep-696-type-defaults-for-typevarlikes
sanic_type = TypeVar(
"sanic_type", bound=Sanic, default=Sanic[Config, SimpleNamespace]
)
else:
sanic_type = TypeVar("sanic_type")
ctx_type = TypeVar("ctx_type")
class Request:
class Request(Generic[sanic_type, ctx_type]):
"""
Properties of an HTTP request such as URL, headers, etc.
"""
@@ -80,6 +96,7 @@ class Request:
__slots__ = (
"__weakref__",
"_cookies",
"_ctx",
"_id",
"_ip",
"_parsed_url",
@@ -96,7 +113,6 @@ class Request:
"app",
"body",
"conn_info",
"ctx",
"head",
"headers",
"method",
@@ -125,7 +141,7 @@ class Request:
version: str,
method: str,
transport: TransportProtocol,
app: Sanic,
app: sanic_type,
head: bytes = b"",
stream_id: int = 0,
):
@@ -149,7 +165,7 @@ class Request:
# Init but do not inhale
self.body = b""
self.conn_info: Optional[ConnInfo] = None
self.ctx = SimpleNamespace()
self._ctx: Optional[ctx_type] = None
self.parsed_accept: Optional[AcceptList] = None
self.parsed_args: DefaultDict[
Tuple[bool, bool, str, str], RequestParameters
@@ -176,6 +192,10 @@ class Request:
class_name = self.__class__.__name__
return f"<{class_name}: {self.method} {self.path}>"
@staticmethod
def make_context() -> ctx_type:
return cast(ctx_type, SimpleNamespace())
@classmethod
def get_current(cls) -> Request:
"""
@@ -205,6 +225,15 @@ class Request:
def generate_id(*_):
return uuid.uuid4()
@property
def ctx(self) -> ctx_type:
"""
:return: The current request context
"""
if not self._ctx:
self._ctx = self.make_context()
return self._ctx
@property
def stream_id(self):
"""
@@ -809,19 +838,31 @@ class Request:
@property
def remote_addr(self) -> str:
"""
Client IP address, if available.
1. proxied remote address `self.forwarded['for']`
2. local remote address `self.ip`
Client IP address, if available from proxy.
:return: IPv4, bracketed IPv6, UNIX socket name or arbitrary string
:rtype: str
"""
if not hasattr(self, "_remote_addr"):
self._remote_addr = str(
self.forwarded.get("for", "")
) # or self.ip
self._remote_addr = str(self.forwarded.get("for", ""))
return self._remote_addr
@property
def client_ip(self) -> str:
"""
Client IP address.
1. proxied remote address `self.forwarded['for']`
2. local peer address `self.ip`
New in Sanic 23.6. Prefer this over `remote_addr` for determining the
client address regardless of whether the service runs behind a proxy
or not (proxy deployment needs separate configuration).
:return: IPv4, bracketed IPv6, UNIX socket name or arbitrary string
:rtype: str
"""
return self.remote_addr or self.ip
@property
def scheme(self) -> str:
"""

View File

@@ -75,7 +75,7 @@ class Router(BaseRouter):
strict_slashes: bool = False,
stream: bool = False,
ignore_body: bool = False,
version: Union[str, float, int] = None,
version: Optional[Union[str, float, int]] = None,
name: Optional[str] = None,
unquote: bool = False,
static: bool = False,

View File

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

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
import sys
from ssl import SSLContext
from typing import TYPE_CHECKING, Dict, Optional, Type, Union
@@ -251,8 +249,7 @@ def _serve_http_1(
loop.run_until_complete(asyncio.sleep(0.1))
start_shutdown = start_shutdown + 0.1
if sys.version_info > (3, 7):
app.shutdown_tasks(graceful - start_shutdown)
app.shutdown_tasks(graceful - start_shutdown)
# Force close non-idle connection after waiting for
# graceful_shutdown_timeout

View File

@@ -96,6 +96,7 @@ class WebsocketFrameAssembler:
If ``timeout`` is set and elapses before a complete message is
received, :meth:`get` returns ``None``.
"""
completed: bool
async with self.read_mutex:
if timeout is not None and timeout <= 0:
if not self.message_complete.is_set():

View File

@@ -21,7 +21,7 @@ from websockets.frames import Frame, Opcode
try: # websockets < 11.0
from websockets.connection import Event, State
from websockets.connection import Event, State # type: ignore
from websockets.server import ServerConnection as ServerProtocol
except ImportError: # websockets >= 11.0
from websockets.protocol import Event, State # type: ignore

View File

@@ -16,11 +16,11 @@ from sanic.models.handler_types import SignalHandler
class Event(Enum):
SERVER_EXCEPTION_REPORT = "server.exception.report"
SERVER_INIT_AFTER = "server.init.after"
SERVER_INIT_BEFORE = "server.init.before"
SERVER_SHUTDOWN_AFTER = "server.shutdown.after"
SERVER_SHUTDOWN_BEFORE = "server.shutdown.before"
SERVER_LIFECYCLE_EXCEPTION = "server.lifecycle.exception"
HTTP_LIFECYCLE_BEGIN = "http.lifecycle.begin"
HTTP_LIFECYCLE_COMPLETE = "http.lifecycle.complete"
HTTP_LIFECYCLE_EXCEPTION = "http.lifecycle.exception"
@@ -40,11 +40,11 @@ class Event(Enum):
RESERVED_NAMESPACES = {
"server": (
Event.SERVER_EXCEPTION_REPORT.value,
Event.SERVER_INIT_AFTER.value,
Event.SERVER_INIT_BEFORE.value,
Event.SERVER_SHUTDOWN_AFTER.value,
Event.SERVER_SHUTDOWN_BEFORE.value,
Event.SERVER_LIFECYCLE_EXCEPTION.value,
),
"http": (
Event.HTTP_LIFECYCLE_BEGIN.value,
@@ -174,11 +174,12 @@ class SignalRouter(BaseRouter):
if self.ctx.app.debug and self.ctx.app.state.verbosity >= 1:
error_logger.exception(e)
if event != Event.SERVER_LIFECYCLE_EXCEPTION.value:
if event != Event.SERVER_EXCEPTION_REPORT.value:
await self.dispatch(
Event.SERVER_LIFECYCLE_EXCEPTION.value,
Event.SERVER_EXCEPTION_REPORT.value,
context={"exception": e},
)
setattr(e, "__dispatched__", True)
raise e
finally:
for signal_event in events:
@@ -229,14 +230,6 @@ class SignalRouter(BaseRouter):
if not trigger:
event = ".".join([*parts[:2], "<__trigger__>"])
try:
# Attaching __requirements__ and __trigger__ to the handler
# is deprecated and will be removed in v23.6.
handler.__requirements__ = condition # type: ignore
handler.__trigger__ = trigger # type: ignore
except AttributeError:
pass
signal = super().add(
event,
handler,

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import os
import sys
from asyncio import new_event_loop
from itertools import chain
from multiprocessing.connection import Connection
@@ -64,7 +63,7 @@ class Reloader:
trigger_events(before_trigger, loop, app)
self.reload(",".join(changed) if changed else "unknown")
if after_trigger:
trigger_events(after_trigger, loop, app)
trigger_events(after_trigger, loop, app, changed=changed)
sleep(self.interval)
else:
if reloader_stop:

View File

@@ -83,12 +83,11 @@ setup_kwargs = {
"packages": find_packages(exclude=("tests", "tests.*")),
"package_data": {"sanic": ["py.typed", "pages/styles/*"]},
"platforms": "any",
"python_requires": ">=3.7",
"python_requires": ">=3.8",
"classifiers": [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
@@ -104,7 +103,7 @@ ujson = "ujson>=1.35" + env_dependency
uvloop = "uvloop>=0.15.0" + env_dependency
types_ujson = "types-ujson" + env_dependency
requirements = [
"sanic-routing>=22.8.0",
"sanic-routing>=23.6.0",
"httptools>=0.0.10",
uvloop,
ujson,
@@ -113,10 +112,11 @@ requirements = [
"multidict>=5.0,<7.0",
"html5tagger>=1.2.1",
"tracerite>=1.0.0",
"typing-extensions>=4.4.0",
]
tests_require = [
"sanic-testing>=23.3.0",
"sanic-testing>=23.6.0",
"pytest==7.1.*",
"coverage",
"beautifulsoup4",
@@ -127,7 +127,7 @@ tests_require = [
"black",
"isort>=5.0.0",
"bandit",
"mypy>=0.901,<0.910",
"mypy",
"docutils",
"pygments",
"uvicorn<0.15.0",

View File

@@ -293,7 +293,7 @@ def test_handle_request_with_nested_sanic_exception(
def test_app_name_required():
with pytest.raises(SanicException):
with pytest.raises(TypeError):
Sanic()

View File

@@ -2,6 +2,8 @@ import logging
import pytest
import sanic
from sanic import Sanic
from sanic.config import Config
from sanic.errorpages import TextRenderer, exception_response, guess_mime
@@ -205,6 +207,27 @@ def test_route_error_response_from_explicit_format(app):
assert response.content_type == "text/plain; charset=utf-8"
def test_blueprint_error_response_from_explicit_format(app):
bp = sanic.Blueprint("MyBlueprint")
@bp.get("/text", error_format="json")
def text_response(request):
raise Exception("oops")
return text("Never gonna see this")
@bp.get("/json", error_format="text")
def json_response(request):
raise Exception("oops")
return json({"message": "Never gonna see this"})
app.blueprint(bp)
_, response = app.test_client.get("/text")
assert response.content_type == "application/json"
_, response = app.test_client.get("/json")
assert response.content_type == "text/plain; charset=utf-8"
def test_unknown_fallback_format(app):
with pytest.raises(SanicException, match="Unknown format: bad"):
app.config.FALLBACK_ERROR_FORMAT = "bad"

View File

@@ -17,6 +17,7 @@ from sanic.response import text
CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True}
PORT = 42001 # test_keep_alive_timeout_reuse doesn't work with random port
MAX_LOOPS = 15
port_counter = count()
@@ -69,23 +70,35 @@ def test_keep_alive_timeout_reuse():
"""If the server keep-alive timeout and client keep-alive timeout are
both longer than the delay, the client _and_ server will successfully
reuse the existing connection."""
port = get_port()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = ReusableClient(keep_alive_timeout_app_reuse, loop=loop, port=port)
with client:
headers = {"Connection": "keep-alive"}
request, response = client.get("/1", headers=headers)
assert response.status == 200
assert response.text == "OK"
assert request.protocol.state["requests_count"] == 1
loops = 0
while True:
port = get_port()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = ReusableClient(
keep_alive_timeout_app_reuse, loop=loop, port=port
)
try:
with client:
headers = {"Connection": "keep-alive"}
request, response = client.get("/1", headers=headers)
assert response.status == 200
assert response.text == "OK"
assert request.protocol.state["requests_count"] == 1
loop.run_until_complete(aio_sleep(1))
loop.run_until_complete(aio_sleep(1))
request, response = client.get("/1")
assert response.status == 200
assert response.text == "OK"
assert request.protocol.state["requests_count"] == 2
request, response = client.get("/1")
assert response.status == 200
assert response.text == "OK"
assert request.protocol.state["requests_count"] == 2
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break
@pytest.mark.skipif(
@@ -97,23 +110,35 @@ def test_keep_alive_timeout_reuse():
def test_keep_alive_client_timeout():
"""If the server keep-alive timeout is longer than the client
keep-alive timeout, client will try to create a new connection here."""
port = get_port()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = ReusableClient(
keep_alive_app_client_timeout, loop=loop, port=port
)
with client:
headers = {"Connection": "keep-alive"}
request, response = client.get("/1", headers=headers, timeout=1)
loops = 0
while True:
try:
port = get_port()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = ReusableClient(
keep_alive_app_client_timeout, loop=loop, port=port
)
with client:
headers = {"Connection": "keep-alive"}
request, response = client.get(
"/1", headers=headers, timeout=1
)
assert response.status == 200
assert response.text == "OK"
assert request.protocol.state["requests_count"] == 1
assert response.status == 200
assert response.text == "OK"
assert request.protocol.state["requests_count"] == 1
loop.run_until_complete(aio_sleep(2))
request, response = client.get("/1", timeout=1)
assert request.protocol.state["requests_count"] == 1
loop.run_until_complete(aio_sleep(2))
request, response = client.get("/1", timeout=1)
assert request.protocol.state["requests_count"] == 1
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break
@pytest.mark.skipif(
@@ -125,24 +150,36 @@ def test_keep_alive_server_timeout():
keep-alive timeout, the client will either a 'Connection reset' error
_or_ a new connection. Depending on how the event-loop handles the
broken server connection."""
port = get_port()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = ReusableClient(
keep_alive_app_server_timeout, loop=loop, port=port
)
with client:
headers = {"Connection": "keep-alive"}
request, response = client.get("/1", headers=headers, timeout=60)
loops = 0
while True:
try:
port = get_port()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = ReusableClient(
keep_alive_app_server_timeout, loop=loop, port=port
)
with client:
headers = {"Connection": "keep-alive"}
request, response = client.get(
"/1", headers=headers, timeout=60
)
assert response.status == 200
assert response.text == "OK"
assert request.protocol.state["requests_count"] == 1
assert response.status == 200
assert response.text == "OK"
assert request.protocol.state["requests_count"] == 1
loop.run_until_complete(aio_sleep(3))
request, response = client.get("/1", timeout=60)
loop.run_until_complete(aio_sleep(3))
request, response = client.get("/1", timeout=60)
assert request.protocol.state["requests_count"] == 1
assert request.protocol.state["requests_count"] == 1
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break
@pytest.mark.skipif(
@@ -150,20 +187,34 @@ def test_keep_alive_server_timeout():
reason="Not testable with current client",
)
def test_keep_alive_connection_context():
port = get_port()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = ReusableClient(keep_alive_app_context, loop=loop, port=port)
with client:
headers = {"Connection": "keep-alive"}
request1, _ = client.post("/ctx", headers=headers)
loops = 0
while True:
try:
port = get_port()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
client = ReusableClient(
keep_alive_app_context, loop=loop, port=port
)
with client:
headers = {"Connection": "keep-alive"}
request1, _ = client.post("/ctx", headers=headers)
loop.run_until_complete(aio_sleep(1))
request2, response = client.get("/ctx")
loop.run_until_complete(aio_sleep(1))
request2, response = client.get("/ctx")
assert response.text == "hello"
assert id(request1.conn_info.ctx) == id(request2.conn_info.ctx)
assert (
request1.conn_info.ctx.foo == request2.conn_info.ctx.foo == "hello"
)
assert request2.protocol.state["requests_count"] == 2
assert response.text == "hello"
assert id(request1.conn_info.ctx) == id(request2.conn_info.ctx)
assert (
request1.conn_info.ctx.foo
== request2.conn_info.ctx.foo
== "hello"
)
assert request2.protocol.state["requests_count"] == 2
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break

View File

@@ -126,7 +126,7 @@ def test_redirect_with_params(app, test_str):
@app.route("/api/v2/test/<test>/", unquote=True)
async def target_handler(request, test):
assert test == test_str
assert test == quote(test_str)
return text("OK")
_, response = app.test_client.get(f"/api/v1/test/{use_in_uri}/")

View File

@@ -310,3 +310,29 @@ def test_request_idempotent(method, idempotent):
def test_request_cacheable(method, cacheable):
request = Request(b"/", {}, None, method, None, None)
assert request.is_cacheable is cacheable
def test_custom_ctx():
class CustomContext:
FOO = "foo"
class CustomRequest(Request[Sanic, CustomContext]):
@staticmethod
def make_context() -> CustomContext:
return CustomContext()
app = Sanic("Test", request_class=CustomRequest)
@app.get("/")
async def handler(request: CustomRequest):
return response.json(
[
isinstance(request, CustomRequest),
isinstance(request.ctx, CustomContext),
request.ctx.FOO,
]
)
_, resp = app.test_client.get("/")
assert resp.json == [True, True, "foo"]

View File

@@ -513,6 +513,7 @@ def test_standard_forwarded(app):
request, response = app.test_client.get("/", headers=headers)
assert response.json == {"for": "127.0.0.2", "proto": "ws"}
assert request.remote_addr == "127.0.0.2"
assert request.client_ip == "127.0.0.2"
assert request.scheme == "ws"
assert request.server_name == "local.site"
assert request.server_port == 80
@@ -737,6 +738,7 @@ def test_remote_addr_with_two_proxies(app):
headers = {"X-Forwarded-For": "127.0.1.1"}
request, response = app.test_client.get("/", headers=headers)
assert request.remote_addr == ""
assert request.client_ip == "127.0.0.1"
assert response.body == b""
headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"}

View File

@@ -1,12 +1,14 @@
import asyncio
import os
import signal
from queue import Queue
from types import SimpleNamespace
from typing import Optional
from unittest.mock import MagicMock
import pytest
from sanic_testing.testing import HOST, PORT
from sanic import Sanic
@@ -158,7 +160,7 @@ def test_signal_server_lifecycle_exception(app: Sanic):
async def hello_route(request):
return HTTPResponse()
@app.signal(Event.SERVER_LIFECYCLE_EXCEPTION)
@app.signal(Event.SERVER_EXCEPTION_REPORT)
async def test_signal(exception: Exception):
nonlocal trigger
trigger = exception

View File

@@ -2,6 +2,7 @@ import asyncio
from enum import Enum
from inspect import isawaitable
from itertools import count
import pytest
@@ -9,6 +10,7 @@ from sanic_routing.exceptions import NotFound
from sanic import Blueprint, Sanic, empty
from sanic.exceptions import InvalidSignal, SanicException
from sanic.signals import Event
def test_add_signal(app):
@@ -427,3 +429,114 @@ def test_signal_reservation(app, event, expected):
app.signal(event)(lambda: ...)
else:
app.signal(event)(lambda: ...)
@pytest.mark.asyncio
async def test_report_exception(app: Sanic):
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
...
@app.route("/")
async def handler(request):
1 / 0
app.signal_router.finalize()
registered_signal_handlers = [
handler
for handler, *_ in app.signal_router.get(
Event.SERVER_EXCEPTION_REPORT.value
)
]
assert catch_any_exception in registered_signal_handlers
def test_report_exception_runs(app: Sanic):
event = asyncio.Event()
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
event.set()
@app.route("/")
async def handler(request):
1 / 0
app.test_client.get("/")
assert event.is_set()
def test_report_exception_runs_once_inline(app: Sanic):
event = asyncio.Event()
c = count()
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
event.set()
next(c)
@app.route("/")
async def handler(request):
...
@app.signal(Event.HTTP_ROUTING_AFTER.value)
async def after_routing(**_):
1 / 0
app.test_client.get("/")
assert event.is_set()
assert next(c) == 1
def test_report_exception_runs_once_custom(app: Sanic):
event = asyncio.Event()
c = count()
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
event.set()
next(c)
@app.route("/")
async def handler(request):
await app.dispatch("one.two.three")
return empty()
@app.signal("one.two.three")
async def one_two_three(**_):
1 / 0
app.test_client.get("/")
assert event.is_set()
assert next(c) == 1
def test_report_exception_runs_task(app: Sanic):
c = count()
async def task_1():
next(c)
async def task_2(app):
next(c)
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
next(c)
@app.route("/")
async def handler(request):
app.add_task(task_1)
app.add_task(task_1())
app.add_task(task_2)
app.add_task(task_2(app))
return empty()
app.test_client.get("/")
assert next(c) == 4

View File

@@ -0,0 +1,10 @@
from sanic import Sanic
from sanic.config import Config
class CustomConfig(Config):
pass
app = Sanic("test", config=CustomConfig())
reveal_type(app)

View File

@@ -0,0 +1,9 @@
from sanic import Sanic
class Foo:
pass
app = Sanic("test", ctx=Foo())
reveal_type(app)

View File

@@ -0,0 +1,5 @@
from sanic import Sanic
app = Sanic("test")
reveal_type(app)

View File

@@ -0,0 +1,14 @@
from sanic import Sanic
from sanic.config import Config
class CustomConfig(Config):
pass
class Foo:
pass
app = Sanic("test", config=CustomConfig(), ctx=Foo())
reveal_type(app)

View File

@@ -0,0 +1,17 @@
from types import SimpleNamespace
from sanic import Request, Sanic
from sanic.config import Config
class Foo:
pass
app = Sanic("test")
@app.get("/")
async def handler(request: Request[Sanic[Config, SimpleNamespace], Foo]):
reveal_type(request.ctx)
reveal_type(request.app)

View File

@@ -0,0 +1,19 @@
from types import SimpleNamespace
from sanic import Request, Sanic
from sanic.config import Config
class CustomConfig(Config):
pass
app = Sanic("test", config=CustomConfig())
@app.get("/")
async def handler(
request: Request[Sanic[CustomConfig, SimpleNamespace], SimpleNamespace]
):
reveal_type(request.ctx)
reveal_type(request.app)

View File

@@ -0,0 +1,34 @@
from sanic import Request, Sanic
from sanic.config import Config
class CustomConfig(Config):
pass
class Foo:
pass
class RequestContext:
foo: Foo
class CustomRequest(Request[Sanic[CustomConfig, Foo], RequestContext]):
@staticmethod
def make_context() -> RequestContext:
ctx = RequestContext()
ctx.foo = Foo()
return ctx
app = Sanic(
"test", config=CustomConfig(), ctx=Foo(), request_class=CustomRequest
)
@app.get("/")
async def handler(request: CustomRequest):
reveal_type(request)
reveal_type(request.ctx)
reveal_type(request.app)

127
tests/typing/test_typing.py Normal file
View File

@@ -0,0 +1,127 @@
# flake8: noqa: E501
import subprocess
import sys
from pathlib import Path
from typing import List, Tuple
import pytest
CURRENT_DIR = Path(__file__).parent
def run_check(path_location: str) -> str:
"""Use mypy to check the given path location and return the output."""
mypy_path = "mypy"
path = CURRENT_DIR / path_location
command = [mypy_path, path.resolve().as_posix()]
process = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
output = process.stdout + process.stderr
return output
@pytest.mark.parametrize(
"path_location,expected",
(
(
"app_default.py",
[
(
"sanic.app.Sanic[sanic.config.Config, types.SimpleNamespace]",
5,
)
],
),
(
"app_custom_config.py",
[
(
"sanic.app.Sanic[app_custom_config.CustomConfig, types.SimpleNamespace]",
10,
)
],
),
(
"app_custom_ctx.py",
[("sanic.app.Sanic[sanic.config.Config, app_custom_ctx.Foo]", 9)],
),
(
"app_fully_custom.py",
[
(
"sanic.app.Sanic[app_fully_custom.CustomConfig, app_fully_custom.Foo]",
14,
)
],
),
(
"request_custom_sanic.py",
[
("types.SimpleNamespace", 18),
(
"sanic.app.Sanic[request_custom_sanic.CustomConfig, types.SimpleNamespace]",
19,
),
],
),
(
"request_custom_ctx.py",
[
("request_custom_ctx.Foo", 16),
(
"sanic.app.Sanic[sanic.config.Config, types.SimpleNamespace]",
17,
),
],
),
(
"request_fully_custom.py",
[
("request_fully_custom.CustomRequest", 32),
("request_fully_custom.RequestContext", 33),
(
"sanic.app.Sanic[request_fully_custom.CustomConfig, request_fully_custom.Foo]",
34,
),
],
),
),
)
def test_check_app_default(
path_location: str, expected: List[Tuple[str, int]]
) -> None:
output = run_check(f"samples/{path_location}")
for text, number in expected:
current = CURRENT_DIR / f"samples/{path_location}"
path = current.relative_to(CURRENT_DIR.parent)
target = Path.cwd()
while True:
note = _text_from_path(current, path, target, number, text)
try:
assert note in output, output
except AssertionError:
target = target.parent
if not target.exists():
raise
else:
break
def _text_from_path(
base: Path, path: Path, target: Path, number: int, text: str
) -> str:
relative_to_cwd = base.relative_to(target)
prefix = ".".join(relative_to_cwd.parts[:-1])
text = text.replace(path.stem, f"{prefix}.{path.stem}")
return f'{path}:{number}: note: Revealed type is "{text}"'

View File

@@ -1,14 +1,14 @@
[tox]
envlist = py37, py38, py39, py310, py311, pyNightly, pypy37, {py37,py38,py39,py310,py311,pyNightly,pypy37}-no-ext, lint, check, security, docs, type-checking
envlist = py38, py39, py310, py311, pyNightly, pypy310, {py38,py39,py310,py311,pyNightly,pypy310}-no-ext, lint, check, security, docs, type-checking
[testenv]
usedevelop = true
setenv =
{py37,py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UJSON=1
{py37,py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UVLOOP=1
{py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UJSON=1
{py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UVLOOP=1
extras = test, http3
deps =
httpx==0.23
httpx>=0.23
allowlist_externals =
pytest
coverage