Compare commits

..

15 Commits

Author SHA1 Message Date
Adam Hopkins
fa864f0bab Start and restart arbitrary processes 2023-07-09 13:53:14 +03:00
Adam Hopkins
976da69e79 Add a new exception signal for ALL exceptions raised anywhere in application (#2724) 2023-07-09 10:53:36 +03:00
Liam Coatman
11a0b15194 Handle case when headers argument of ResponseStream constructor is None (#2729)
* Handle case when headers is None

* Add test for response stream with default headers

* Move test

---------

Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-09 10:34:40 +03:00
Adam Hopkins
c21999a248 Resolve headers on different renderers - Issue 2749 (#2774)
* Resolve headers on different renderers - Issue 2749

* Make pretty
2023-07-09 09:57:22 +03:00
guacs
c17230ef94 Update request type on middleware types (#2754)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-09 09:35:24 +03:00
Adam Hopkins
049983cb70 Fix traversals for intended results (#2728) 2023-07-09 09:21:39 +03:00
Zhiwei
e374409567 Adding allow route overwrite option in blueprint (#2716)
* Adding allow route overwrite option

* Add test case for route overwriting after bp copy

* Fix test

* Fix

* Add test case `test_bp_allow_override`

* Remove conflicted future routes when overwriting is allowed

* Improved test test_bp_copy_with_route_overwriting

* Fix type

* Fix type 2

* Add `test_bp_copy_without_route_overwriting` case

* make `allow_route_overwrite` flag to be internal

* Remove unwanted test case

---------

Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-07 14:56:42 +03:00
Adam Hopkins
4068a0d83d Add name prefixing to BP groups (#2727) 2023-07-05 19:31:25 +03:00
Benjamin
70da5e9879 Fix Inner bug: TypeError: __init__() got an unexpected keyword argument 'escape_forward_slashes' #2740 (#2772) 2023-07-05 15:30:38 +03:00
Moshe Nahmias
f48506d620 fix #2757 - Improved error messaging on startup time application induced import error (#2770)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-05 14:38:15 +03:00
Mohammad Almoghrabi
f2cc83c1ba fix examples for freeze_support() issue on windows (#2741)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-05 13:45:08 +03:00
Mohammad Almoghrabi
273825dab6 Sanic on pypy (#2682)
Co-authored-by: L. Kärkkäinen <98187+Tronic@users.noreply.github.com>
Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-05 12:14:47 +03:00
Zhiwei
9a7dafd531 Unpin setuptools version (#2766) 2023-07-05 11:06:43 +03:00
Thirumalaisamy K
50117d174c Fix issue in getting current request through classmethod when served through a different ASGI server (#2760) 2023-06-14 22:03:43 +03:00
Néstor Pérez
af67801062 Fix JSONResponse default content type (#2737) 2023-04-09 22:23:21 +03:00
55 changed files with 655 additions and 136 deletions

View File

@@ -4,10 +4,12 @@ on:
push:
branches:
- main
- current-release
- "*LTS"
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
schedule:

View File

@@ -3,12 +3,14 @@ on:
push:
branches:
- main
- current-release
- "*LTS"
tags:
- "!*" # Do not execute on tags
pull_request:
branches:
- main
- current-release
- "*LTS"
jobs:
test:

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]

1
.gitignore vendored
View File

@@ -21,4 +21,5 @@ dist/*
pip-wheel-metadata/
.pytest_cache/*
.venv/*
venv/*
.vscode/*

View File

@@ -25,5 +25,5 @@ def key_exist_handler(request):
return text("num does not exist in request")
app.run(host="0.0.0.0", port=8000, debug=True)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True)

View File

@@ -50,4 +50,5 @@ def pop_handler(request):
app.blueprint(bp, url_prefix="/bp")
app.run(host="0.0.0.0", port=8000, debug=True, auto_reload=False)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True, auto_reload=False)

View File

@@ -37,4 +37,5 @@ app.blueprint(blueprint)
app.blueprint(blueprint2)
app.blueprint(blueprint3)
app.run(host="0.0.0.0", port=9999, debug=True)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=9999, debug=True)

View File

@@ -69,5 +69,5 @@ async def runner(app: Sanic, app_server: AsyncioServer):
app.is_running = False
app.is_stopping = True
https.run(port=HTTPS_PORT, debug=True)
if __name__ == "__main__":
https.run(port=HTTPS_PORT, debug=True)

View File

@@ -39,4 +39,5 @@ async def test(request):
return json(response)
app.run(host="0.0.0.0", port=8000, workers=2)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, workers=2)

View File

@@ -20,4 +20,5 @@ def test(request):
return text("hey")
app.run(host="0.0.0.0", port=8000)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)

View File

@@ -6,5 +6,5 @@ data = ""
for i in range(1, 250000):
data += str(i)
r = requests.post('http://0.0.0.0:8000/stream', data=data)
r = requests.post("http://0.0.0.0:8000/stream", data=data)
print(r.text)

View File

@@ -20,4 +20,5 @@ def timeout(request, exception):
return response.text("RequestTimeout from error_handler.", 408)
app.run(host="0.0.0.0", port=8000)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)

View File

@@ -35,34 +35,34 @@ async def after_server_stop(app, loop):
async def test(request):
return response.json({"answer": "42"})
if __name__ == "__main__":
asyncio.set_event_loop(uvloop.new_event_loop())
serv_coro = app.create_server(
host="0.0.0.0", port=8000, return_asyncio_server=True
)
loop = asyncio.get_event_loop()
serv_task = asyncio.ensure_future(serv_coro, loop=loop)
signal(SIGINT, lambda s, f: loop.stop())
server: AsyncioServer = loop.run_until_complete(serv_task)
loop.run_until_complete(server.startup())
asyncio.set_event_loop(uvloop.new_event_loop())
serv_coro = app.create_server(
host="0.0.0.0", port=8000, return_asyncio_server=True
)
loop = asyncio.get_event_loop()
serv_task = asyncio.ensure_future(serv_coro, loop=loop)
signal(SIGINT, lambda s, f: loop.stop())
server: AsyncioServer = loop.run_until_complete(serv_task)
loop.run_until_complete(server.startup())
# When using app.run(), this actually triggers before the serv_coro.
# But, in this example, we are using the convenience method, even if it is
# out of order.
loop.run_until_complete(server.before_start())
loop.run_until_complete(server.after_start())
try:
loop.run_forever()
except KeyboardInterrupt:
loop.stop()
finally:
loop.run_until_complete(server.before_stop())
# When using app.run(), this actually triggers before the serv_coro.
# But, in this example, we are using the convenience method, even if it is
# out of order.
loop.run_until_complete(server.before_start())
loop.run_until_complete(server.after_start())
try:
loop.run_forever()
except KeyboardInterrupt:
loop.stop()
finally:
loop.run_until_complete(server.before_stop())
# Wait for server to close
close_task = server.close()
loop.run_until_complete(close_task)
# Wait for server to close
close_task = server.close()
loop.run_until_complete(close_task)
# Complete all tasks on the loop
for connection in server.connections:
connection.close_if_idle()
loop.run_until_complete(server.after_stop())
# Complete all tasks on the loop
for connection in server.connections:
connection.close_if_idle()
loop.run_until_complete(server.after_stop())

View File

@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools<60.0", "wheel"]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[tool.black]

View File

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

View File

@@ -5,7 +5,6 @@ import logging
import logging.config
import re
import sys
from asyncio import (
AbstractEventLoop,
CancelledError,
@@ -55,12 +54,7 @@ from sanic.blueprint_group import BlueprintGroup
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,
SanicException,
ServerError,
URLBuildError,
)
from sanic.exceptions import BadRequest, SanicException, ServerError, URLBuildError
from sanic.handlers import ErrorHandler
from sanic.helpers import Default, _default
from sanic.http import Stage
@@ -90,7 +84,6 @@ from sanic.worker.inspector import Inspector
from sanic.worker.loader import CertLoader
from sanic.worker.manager import WorkerManager
if TYPE_CHECKING:
try:
from sanic_ext import Extend # type: ignore
@@ -417,8 +410,11 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
def _apply_listener(self, listener: FutureListener):
return self.register_listener(listener.listener, listener.event)
def _apply_route(self, route: FutureRoute) -> List[Route]:
def _apply_route(
self, route: FutureRoute, overwrite: bool = False
) -> List[Route]:
params = route._asdict()
params["overwrite"] = overwrite
websocket = params.pop("websocket", False)
subprotocols = params.pop("subprotocols", None)
@@ -550,6 +546,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
)
else:
params["version_prefix"] = blueprint.version_prefix
name_prefix = getattr(blueprint, "name_prefix", None)
if name_prefix and "name_prefix" not in params:
params["name_prefix"] = name_prefix
self.blueprint(item, **params)
return
if blueprint.name in self.blueprints:
@@ -767,6 +766,10 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
:raises ServerError: response 500
"""
response = None
await self.dispatch(
"server.lifecycle.exception",
context={"exception": exception},
)
await self.dispatch(
"http.lifecycle.exception",
inline=True,
@@ -1666,7 +1669,10 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
def inspector(self):
if environ.get("SANIC_WORKER_PROCESS") or not self._inspector:
raise SanicException(
"Can only access the inspector from the main process"
"Can only access the inspector from the main process "
"after main_process_start has run. For example, you most "
"likely want to use it inside the @app.main_process_ready "
"event listener."
)
return self._inspector
@@ -1674,6 +1680,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
def manager(self):
if environ.get("SANIC_WORKER_PROCESS") or not self._manager:
raise SanicException(
"Can only access the manager from the main process"
"Can only access the manager from the main process "
"after main_process_start has run. For example, you most "
"likely want to use it inside the @app.main_process_ready "
"event listener."
)
return self._manager

View File

@@ -3,7 +3,7 @@ import sys
from os import environ
from sanic.compat import is_atty
from sanic.helpers import is_atty
BASE_LOGO = """

View File

@@ -4,7 +4,7 @@ from textwrap import indent, wrap
from typing import Dict, Optional
from sanic import __version__
from sanic.compat import is_atty
from sanic.helpers import is_atty
from sanic.log import logger

View File

@@ -175,6 +175,7 @@ class ASGIApp:
instance.transport,
sanic_app,
)
request_class._current.set(instance.request)
instance.request.stream = instance # type: ignore
instance.request_body = True
instance.request.conn_info = ConnInfo(instance.transport)

View File

@@ -65,6 +65,7 @@ class BlueprintGroup(MutableSequence):
"_version",
"_strict_slashes",
"_version_prefix",
"_name_prefix",
)
def __init__(
@@ -73,6 +74,7 @@ class BlueprintGroup(MutableSequence):
version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None,
version_prefix: str = "/v",
name_prefix: Optional[str] = "",
):
"""
Create a new Blueprint Group
@@ -87,6 +89,7 @@ class BlueprintGroup(MutableSequence):
self._version = version
self._version_prefix = version_prefix
self._strict_slashes = strict_slashes
self._name_prefix = name_prefix
@property
def url_prefix(self) -> Optional[Union[int, str, float]]:
@@ -134,6 +137,15 @@ class BlueprintGroup(MutableSequence):
"""
return self._version_prefix
@property
def name_prefix(self) -> Optional[str]:
"""
Name prefix for the blueprint group
:return: str
"""
return self._name_prefix
def __iter__(self):
"""
Tun the class Blueprint Group into an Iterable item

View File

@@ -93,6 +93,7 @@ class Blueprint(BaseSanic):
"_future_listeners",
"_future_exceptions",
"_future_signals",
"_allow_route_overwrite",
"copied_from",
"ctx",
"exceptions",
@@ -119,6 +120,7 @@ class Blueprint(BaseSanic):
):
super().__init__(name=name)
self.reset()
self._allow_route_overwrite = False
self.copied_from = ""
self.ctx = SimpleNamespace()
self.host = host
@@ -169,6 +171,7 @@ class Blueprint(BaseSanic):
def reset(self):
self._apps: Set[Sanic] = set()
self._allow_route_overwrite = False
self.exceptions: List[RouteHandler] = []
self.listeners: Dict[str, List[ListenerType[Any]]] = {}
self.middlewares: List[MiddlewareType] = []
@@ -182,6 +185,7 @@ class Blueprint(BaseSanic):
url_prefix: Optional[Union[str, Default]] = _default,
version: Optional[Union[int, str, float, Default]] = _default,
version_prefix: Union[str, Default] = _default,
allow_route_overwrite: Union[bool, Default] = _default,
strict_slashes: Optional[Union[bool, Default]] = _default,
with_registration: bool = True,
with_ctx: bool = False,
@@ -225,6 +229,8 @@ class Blueprint(BaseSanic):
new_bp.strict_slashes = strict_slashes
if not isinstance(version_prefix, Default):
new_bp.version_prefix = version_prefix
if not isinstance(allow_route_overwrite, Default):
new_bp._allow_route_overwrite = allow_route_overwrite
for key, value in attrs_backup.items():
setattr(self, key, value)
@@ -250,6 +256,7 @@ class Blueprint(BaseSanic):
version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None,
version_prefix: str = "/v",
name_prefix: Optional[str] = "",
) -> BlueprintGroup:
"""
Create a list of blueprints, optionally grouping them under a
@@ -275,6 +282,7 @@ class Blueprint(BaseSanic):
version=version,
strict_slashes=strict_slashes,
version_prefix=version_prefix,
name_prefix=name_prefix,
)
for bp in chain(blueprints):
bps.append(bp)
@@ -295,6 +303,7 @@ class Blueprint(BaseSanic):
opt_version = options.get("version", None)
opt_strict_slashes = options.get("strict_slashes", None)
opt_version_prefix = options.get("version_prefix", self.version_prefix)
opt_name_prefix = options.get("name_prefix", None)
error_format = options.get(
"error_format", app.config.FALLBACK_ERROR_FORMAT
)
@@ -326,7 +335,10 @@ class Blueprint(BaseSanic):
future.strict_slashes, opt_strict_slashes, self.strict_slashes
)
name = app._generate_name(future.name)
name = future.name
if opt_name_prefix:
name = f"{opt_name_prefix}_{future.name}"
name = app._generate_name(name)
host = future.host or self.host
if isinstance(host, list):
host = tuple(host)
@@ -354,7 +366,9 @@ class Blueprint(BaseSanic):
continue
registered.add(apply_route)
route = app._apply_route(apply_route)
route = app._apply_route(
apply_route, overwrite=self._allow_route_overwrite
)
# If it is a copied BP, then make sure all of the names of routes
# matchup with the new BP name

View File

@@ -180,6 +180,10 @@ 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"
)
error_logger.error(
"\nThe error below might have caused the above one:\n"
f"{e.msg}"
)
sys.exit(1)
else:
raise e

View File

@@ -1,5 +1,6 @@
import asyncio
import os
import platform
import signal
import sys
@@ -10,6 +11,7 @@ from typing import Awaitable, Union
from multidict import CIMultiDict # type: ignore
from sanic.helpers import Default
from sanic.log import error_logger
if sys.version_info < (3, 8): # no cov
@@ -22,6 +24,7 @@ else: # no cov
]
OS_IS_WINDOWS = os.name == "nt"
PYPY_IMPLEMENTATION = platform.python_implementation() == "PyPy"
UVLOOP_INSTALLED = False
try:
@@ -73,6 +76,38 @@ def enable_windows_color_support():
kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7)
def pypy_os_module_patch() -> None:
"""
The PyPy os module is missing the 'readlink' function, which causes issues
withaiofiles. This workaround replaces the missing 'readlink' function
with 'os.path.realpath', which serves the same purpose.
"""
if hasattr(os, "readlink"):
error_logger.warning(
"PyPy: Skipping patching of the os module as it appears the "
"'readlink' function has been added."
)
return
module = sys.modules["os"]
module.readlink = os.path.realpath # type: ignore
def pypy_windows_set_console_cp_patch() -> None:
"""
A patch function for PyPy on Windows that sets the console code page to
UTF-8 encodingto allow for proper handling of non-ASCII characters. This
function uses ctypes to call the Windows API functions SetConsoleCP and
SetConsoleOutputCP to set the code page.
"""
from ctypes import windll # type: ignore
code: int = windll.kernel32.GetConsoleOutputCP()
if code != 65001:
windll.kernel32.SetConsoleCP(65001)
windll.kernel32.SetConsoleOutputCP(65001)
class Header(CIMultiDict):
"""
Container used for both request and response headers. It is a subclass of
@@ -86,7 +121,7 @@ class Header(CIMultiDict):
<https://multidict.readthedocs.io/en/stable/multidict.html#multidict>`_
for more details about how to use the object. In general, it should work
very similar to a regular dictionary.
"""
""" # noqa: E501
def __getattr__(self, key: str) -> str:
if key.startswith("_"):
@@ -112,6 +147,12 @@ if use_trio: # pragma: no cover
open_async = trio.open_file
CancelledErrors = tuple([asyncio.CancelledError, trio.Cancelled])
else:
if PYPY_IMPLEMENTATION:
pypy_os_module_patch()
if OS_IS_WINDOWS:
pypy_windows_set_console_cp_patch()
from aiofiles import open as aio_open # type: ignore
from aiofiles.os import stat as stat_async # type: ignore # noqa: F401
@@ -143,7 +184,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

@@ -92,8 +92,10 @@ class BaseRenderer:
self.full
if self.debug and not getattr(self.exception, "quiet", False)
else self.minimal
)
return output()
)()
output.status = self.status
output.headers.update(self.headers)
return output
def minimal(self) -> HTTPResponse: # noqa
"""
@@ -125,7 +127,7 @@ class HTMLRenderer(BaseRenderer):
request=self.request,
exc=self.exception,
)
return html(page.render(), status=self.status, headers=self.headers)
return html(page.render())
def minimal(self) -> HTTPResponse:
return self.full()
@@ -146,8 +148,7 @@ class TextRenderer(BaseRenderer):
text=self.text,
bar=("=" * len(self.title)),
body=self._generate_body(full=True),
),
status=self.status,
)
)
def minimal(self) -> HTTPResponse:
@@ -157,9 +158,7 @@ class TextRenderer(BaseRenderer):
text=self.text,
bar=("=" * len(self.title)),
body=self._generate_body(full=False),
),
status=self.status,
headers=self.headers,
)
)
@property
@@ -218,11 +217,11 @@ class JSONRenderer(BaseRenderer):
def full(self) -> HTTPResponse:
output = self._generate_output(full=True)
return json(output, status=self.status, dumps=self.dumps)
return json(output, dumps=self.dumps)
def minimal(self) -> HTTPResponse:
output = self._generate_output(full=False)
return json(output, status=self.status, dumps=self.dumps)
return json(output, dumps=self.dumps)
def _generate_output(self, *, full):
output = {

View File

@@ -6,7 +6,9 @@ from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
from sanic.exceptions import ServerError
from sanic.log import error_logger
from sanic.models.handler_types import RouteHandler
from sanic.request.types import Request
from sanic.response import text
from sanic.response.types import HTTPResponse
class ErrorHandler:
@@ -148,7 +150,7 @@ class ErrorHandler:
return text("An error occurred while handling an error", 500)
return response
def default(self, request, exception):
def default(self, request: Request, exception: Exception) -> HTTPResponse:
"""
Provide a default behavior for the objects of :class:`ErrorHandler`.
If a developer chooses to extent the :class:`ErrorHandler` they can

View File

@@ -1,5 +1,7 @@
"""Defines basics of HTTP standard."""
import sys
from importlib import import_module
from inspect import ismodule
from typing import Dict
@@ -157,6 +159,10 @@ def import_string(module_name, package=None):
return obj()
def is_atty() -> bool:
return bool(sys.stdout and sys.stdout.isatty())
class Default:
"""
It is used to replace `None` or `object()` as a sentinel

View File

@@ -5,7 +5,7 @@ from enum import Enum
from typing import TYPE_CHECKING, Any, Dict
from warnings import warn
from sanic.compat import is_atty
from sanic.helpers import is_atty
# Python 3.11 changed the way Enum formatting works for mixed-in types.

View File

@@ -159,7 +159,11 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
error_format,
route_context,
)
overwrite = getattr(self, "_allow_route_overwrite", False)
if overwrite:
self._future_routes = set(
filter(lambda x: x.uri != uri, self._future_routes)
)
self._future_routes.add(route)
args = list(signature(handler).parameters.keys())
@@ -182,7 +186,7 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
handler.is_stream = stream
if apply:
self._apply_route(route)
self._apply_route(route, overwrite=overwrite)
if static:
return route, handler

View File

@@ -41,9 +41,9 @@ 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, StartMethod, is_atty
from sanic.compat import OS_IS_WINDOWS, StartMethod
from sanic.exceptions import ServerKilled
from sanic.helpers import Default, _default
from sanic.helpers import Default, _default, is_atty
from sanic.http.constants import HTTP
from sanic.http.tls import get_ssl_context, process_to_context
from sanic.http.tls.context import SanicSSLContext

View File

@@ -95,7 +95,7 @@ class StaticMixin(BaseMixin, metaclass=SanicMeta):
)
try:
file_or_directory = Path(file_or_directory)
file_or_directory = Path(file_or_directory).resolve()
except TypeError:
raise TypeError(
"Static file or directory must be a path-like object or string"

View File

@@ -3,11 +3,12 @@ from typing import Any, Callable, Coroutine, Optional, TypeVar, Union
import sanic
from sanic.request import Request
from sanic import request
from sanic.response import BaseHTTPResponse, HTTPResponse
Sanic = TypeVar("Sanic", bound="sanic.Sanic")
Request = TypeVar("Request", bound="request.Request")
MiddlewareResponse = Union[
Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]]

View File

@@ -38,7 +38,9 @@ else:
try:
from ujson import dumps as json_dumps
from ujson import dumps as ujson_dumps
json_dumps = partial(ujson_dumps, escape_forward_slashes=False)
except ImportError:
# This is done in order to ensure that the JSON response is
# kept consistent across both ujson and inbuilt json usage.
@@ -345,7 +347,7 @@ class JSONResponse(HTTPResponse):
body: Optional[Any] = None,
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
content_type: str = "application/json",
dumps: Optional[Callable[..., str]] = None,
**kwargs: Any,
):
@@ -520,7 +522,9 @@ class ResponseStream:
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
if not isinstance(headers, Header):
if headers is None:
headers = Header()
elif not isinstance(headers, Header):
headers = Header(headers)
self.streaming_fn = streaming_fn
self.status = status

View File

@@ -80,6 +80,7 @@ class Router(BaseRouter):
unquote: bool = False,
static: bool = False,
version_prefix: str = "/v",
overwrite: bool = False,
error_format: Optional[str] = None,
) -> Union[Route, List[Route]]:
"""
@@ -122,6 +123,7 @@ class Router(BaseRouter):
name=name,
strict=strict_slashes,
unquote=unquote,
overwrite=overwrite,
)
if isinstance(host, str):

View File

@@ -20,6 +20,7 @@ class Event(Enum):
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"
@@ -43,6 +44,7 @@ RESERVED_NAMESPACES = {
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,
@@ -168,6 +170,16 @@ class SignalRouter(BaseRouter):
elif maybe_coroutine:
return maybe_coroutine
return None
except Exception as e:
if self.ctx.app.debug and self.ctx.app.state.verbosity >= 1:
error_logger.exception(e)
if event != Event.SERVER_LIFECYCLE_EXCEPTION.value:
await self.dispatch(
Event.SERVER_LIFECYCLE_EXCEPTION.value,
context={"exception": e},
)
raise e
finally:
for signal_event in events:
signal_event.clear()

View File

@@ -16,3 +16,5 @@ class ProcessState(IntEnum):
ACKED = auto()
JOINED = auto()
TERMINATED = auto()
FAILED = auto()
COMPLETED = auto()

View File

@@ -83,10 +83,7 @@ class Inspector:
async def _respond(self, request: Request, output: Any):
name = request.match_info.get("action", "info")
return json(
{"meta": {"action": name}, "result": output},
escape_forward_slashes=False,
)
return json({"meta": {"action": name}, "result": output})
def _state_to_json(self) -> Dict[str, Any]:
output = {"info": self.app_info}

View File

@@ -1,11 +1,11 @@
import os
from contextlib import suppress
from itertools import count
from enum import IntEnum, auto
from itertools import chain, count
from random import choice
from signal import SIGINT, SIGTERM, Signals
from signal import signal as signal_func
from typing import Any, Callable, Dict, List, Optional
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
from sanic.compat import OS_IS_WINDOWS
from sanic.exceptions import ServerKilled
@@ -13,13 +13,17 @@ from sanic.log import error_logger, logger
from sanic.worker.constants import RestartOrder
from sanic.worker.process import ProcessState, Worker, WorkerProcess
if not OS_IS_WINDOWS:
from signal import SIGKILL
else:
SIGKILL = SIGINT
class MonitorCycle(IntEnum):
BREAK = auto()
CONTINUE = auto()
class WorkerManager:
THRESHOLD = WorkerProcess.THRESHOLD
MAIN_IDENT = "Sanic-Main"
@@ -60,6 +64,8 @@ class WorkerManager:
func: Callable[..., Any],
kwargs: Dict[str, Any],
transient: bool = False,
restartable: Optional[bool] = None,
tracked: bool = True,
workers: int = 1,
) -> Worker:
"""
@@ -75,14 +81,35 @@ class WorkerManager:
then the Worker Manager will restart the process along
with any global restart (ex: auto-reload), defaults to False
:type transient: bool, optional
:param restartable: Whether to mark the process as restartable. If
True then the Worker Manager will be able to restart the process
if prompted. If transient=True, this property will be implied
to be True, defaults to None
:type restartable: Optional[bool], optional
:param tracked: Whether to track the process after completion,
defaults to True
:param workers: The number of worker processes to run, defaults to 1
:type workers: int, optional
:return: The Worker instance
:rtype: Worker
"""
if ident in self.transient or ident in self.durable:
raise ValueError(f"Worker {ident} already exists")
restartable = restartable if restartable is not None else transient
if transient and not restartable:
raise ValueError(
"Cannot create a transient worker that is not restartable"
)
container = self.transient if transient else self.durable
worker = Worker(
ident, func, kwargs, self.context, self.worker_state, workers
ident,
func,
kwargs,
self.context,
self.worker_state,
workers,
restartable,
tracked,
)
container[worker.ident] = worker
return worker
@@ -94,6 +121,7 @@ class WorkerManager:
self._serve,
self._server_settings,
transient=True,
restartable=True,
)
def shutdown_server(self, ident: Optional[str] = None) -> None:
@@ -153,9 +181,32 @@ class WorkerManager:
restart_order=RestartOrder.SHUTDOWN_FIRST,
**kwargs,
):
restarted = set()
for process in self.transient_processes:
if not process_names or process.name in process_names:
if process.restartable and (
not process_names or process.name in process_names
):
process.restart(restart_order=restart_order, **kwargs)
restarted.add(process.name)
if process_names:
for process in self.durable_processes:
if process.restartable and process.name in process_names:
if process.state not in (
ProcessState.COMPLETED,
ProcessState.FAILED,
):
error_logger.error(
f"Cannot restart process {process.name} because "
"it is not in a final state. Current state is: "
f"{process.state.name}."
)
continue
process.restart(restart_order=restart_order, **kwargs)
restarted.add(process.name)
if process_names and not restarted:
error_logger.error(
f"Failed to restart processes: {', '.join(process_names)}"
)
def scale(self, num_worker: int):
if num_worker <= 0:
@@ -183,45 +234,13 @@ class WorkerManager:
self.wait_for_ack()
while True:
try:
if self.monitor_subscriber.poll(0.1):
message = self.monitor_subscriber.recv()
logger.debug(
f"Monitor message: {message}", extra={"verbosity": 2}
)
if not message:
break
elif message == "__TERMINATE__":
self.shutdown()
break
logger.debug(
"Incoming monitor message: %s",
message,
extra={"verbosity": 1},
)
split_message = message.split(":", 2)
if message.startswith("__SCALE__"):
self.scale(int(split_message[-1]))
continue
processes = split_message[0]
reloaded_files = (
split_message[1] if len(split_message) > 1 else None
)
process_names = [
name.strip() for name in processes.split(",")
]
if "__ALL_PROCESSES__" in process_names:
process_names = None
order = (
RestartOrder.STARTUP_FIRST
if "STARTUP_FIRST" in split_message
else RestartOrder.SHUTDOWN_FIRST
)
self.restart(
process_names=process_names,
reloaded_files=reloaded_files,
restart_order=order,
)
cycle = self._poll_monitor()
if cycle is MonitorCycle.BREAK:
break
elif cycle is MonitorCycle.CONTINUE:
continue
self._sync_states()
self._cleanup_non_tracked_workers()
except InterruptedError:
if not OS_IS_WINDOWS:
raise
@@ -264,6 +283,10 @@ class WorkerManager:
def workers(self) -> List[Worker]:
return list(self.transient.values()) + list(self.durable.values())
@property
def all_workers(self) -> Iterable[Tuple[str, Worker]]:
return chain(self.transient.items(), self.durable.items())
@property
def processes(self):
for worker in self.workers:
@@ -276,6 +299,12 @@ class WorkerManager:
for process in worker.processes:
yield process
@property
def durable_processes(self):
for worker in self.durable.values():
for process in worker.processes:
yield process
def kill(self):
for process in self.processes:
logger.info("Killing %s [%s]", process.name, process.pid)
@@ -298,6 +327,25 @@ class WorkerManager:
process.terminate()
self._shutting_down = True
def remove_worker(self, worker: Worker) -> None:
if worker.tracked:
error_logger.error(
f"Worker {worker.ident} is tracked and cannot be removed."
)
return
if worker.has_alive_processes():
error_logger.error(
f"Worker {worker.ident} has alive processes and cannot be "
"removed."
)
return
self.transient.pop(worker.ident, None)
self.durable.pop(worker.ident, None)
for process in worker.processes:
self.worker_state.pop(process.name, None)
logger.info("Removed worker %s", worker.ident)
del worker
@property
def pid(self):
return os.getpid()
@@ -317,5 +365,97 @@ class WorkerManager:
except KeyError:
process.set_state(ProcessState.TERMINATED, True)
continue
if not process.is_alive():
state = "FAILED" if process.exitcode else "COMPLETED"
if state and process.state.name != state:
process.set_state(ProcessState[state], True)
def _cleanup_non_tracked_workers(self) -> None:
to_remove = [
worker
for worker in self.workers
if not worker.tracked and not worker.has_alive_processes()
]
for worker in to_remove:
self.remove_worker(worker)
def _poll_monitor(self) -> Optional[MonitorCycle]:
if self.monitor_subscriber.poll(0.1):
message = self.monitor_subscriber.recv()
logger.debug(f"Monitor message: {message}", extra={"verbosity": 2})
if not message:
return MonitorCycle.BREAK
elif message == "__TERMINATE__":
self._handle_terminate()
return MonitorCycle.BREAK
elif isinstance(message, tuple) and len(message) == 7:
self._handle_manage(*message)
return MonitorCycle.CONTINUE
elif not isinstance(message, str):
error_logger.error(
"Monitor received an invalid message: %s", message
)
return MonitorCycle.CONTINUE
return self._handle_message(message)
return None
def _handle_terminate(self) -> None:
self.shutdown()
def _handle_message(self, message: str) -> Optional[MonitorCycle]:
logger.debug(
"Incoming monitor message: %s",
message,
extra={"verbosity": 1},
)
split_message = message.split(":", 2)
if message.startswith("__SCALE__"):
self.scale(int(split_message[-1]))
return MonitorCycle.CONTINUE
processes = split_message[0]
reloaded_files = split_message[1] if len(split_message) > 1 else None
process_names: Optional[List[str]] = [
name.strip() for name in processes.split(",")
]
if process_names and "__ALL_PROCESSES__" in process_names:
process_names = None
order = (
RestartOrder.STARTUP_FIRST
if "STARTUP_FIRST" in split_message
else RestartOrder.SHUTDOWN_FIRST
)
self.restart(
process_names=process_names,
reloaded_files=reloaded_files,
restart_order=order,
)
return None
def _handle_manage(
self,
ident: str,
func: Callable[..., Any],
kwargs: Dict[str, Any],
transient: bool,
restartable: Optional[bool],
tracked: bool,
workers: int,
) -> None:
try:
worker = self.manage(
ident,
func,
kwargs,
transient=transient,
restartable=restartable,
tracked=tracked,
workers=workers,
)
except Exception:
error_logger.exception("Failed to manage worker %s", ident)
else:
for process in worker.processes:
process.start()

View File

@@ -1,6 +1,6 @@
from multiprocessing.connection import Connection
from os import environ, getpid
from typing import Any, Dict
from typing import Any, Callable, Dict, Optional
from sanic.log import Colors, logger
from sanic.worker.process import ProcessState
@@ -28,6 +28,27 @@ class WorkerMultiplexer:
"state": ProcessState.ACKED.name,
}
def manage(
self,
ident: str,
func: Callable[..., Any],
kwargs: Dict[str, Any],
transient: bool = False,
restartable: Optional[bool] = None,
tracked: bool = False,
workers: int = 1,
) -> None:
bundle = (
ident,
func,
kwargs,
transient,
restartable,
tracked,
workers,
)
self._monitor_publisher.send(bundle)
def restart(
self,
name: str = "",

View File

@@ -1,5 +1,4 @@
import os
from datetime import datetime, timezone
from multiprocessing.context import BaseContext
from signal import SIGINT
@@ -20,13 +19,22 @@ class WorkerProcess:
THRESHOLD = 300 # == 30 seconds
SERVER_LABEL = "Server"
def __init__(self, factory, name, target, kwargs, worker_state):
def __init__(
self,
factory,
name,
target,
kwargs,
worker_state,
restartable: bool = False,
):
self.state = ProcessState.IDLE
self.factory = factory
self.name = name
self.target = target
self.kwargs = kwargs
self.worker_state = worker_state
self.restartable = restartable
if self.name not in self.worker_state:
self.worker_state[self.name] = {
"server": self.SERVER_LABEL in self.name
@@ -132,6 +140,10 @@ class WorkerProcess:
def pid(self):
return self._current_process.pid
@property
def exitcode(self):
return self._current_process.exitcode
def _terminate_now(self):
logger.debug(
f"{Colors.BLUE}Begin restart termination: "
@@ -193,6 +205,8 @@ class Worker:
context: BaseContext,
worker_state: Dict[str, Any],
num: int = 1,
restartable: bool = False,
tracked: bool = True,
):
self.ident = ident
self.num = num
@@ -201,6 +215,8 @@ class Worker:
self.server_settings = server_settings
self.worker_state = worker_state
self.processes: Set[WorkerProcess] = set()
self.restartable = restartable
self.tracked = tracked
for _ in range(num):
self.create_process()
@@ -215,6 +231,10 @@ class Worker:
target=self.serve,
kwargs={**self.server_settings},
worker_state=self.worker_state,
restartable=self.restartable,
)
self.processes.add(process)
return process
def has_alive_processes(self) -> bool:
return any(process.is_alive() for process in self.processes)

View File

@@ -1,4 +1,8 @@
from sanic import Blueprint, Sanic
import pytest
from sanic_routing.exceptions import RouteExists
from sanic import Blueprint, Request, Sanic
from sanic.response import text
@@ -74,3 +78,76 @@ def test_bp_copy(app: Sanic):
assert "test_bp_copy.test_bp4.handle_request" in route_names
assert "test_bp_copy.test_bp5.handle_request" in route_names
assert "test_bp_copy.test_bp6.handle_request" in route_names
def test_bp_copy_without_route_overwriting(app: Sanic):
bpv1 = Blueprint("bp_v1", version=1, url_prefix="my_api")
@bpv1.route("/")
async def handler(request: Request):
return text("v1")
app.blueprint(bpv1)
bpv2 = bpv1.copy("bp_v2", version=2, allow_route_overwrite=False)
bpv3 = bpv1.copy(
"bp_v3",
version=3,
allow_route_overwrite=False,
with_registration=False,
)
with pytest.raises(RouteExists, match="Route already registered*"):
@bpv2.route("/")
async def handler(request: Request):
return text("v2")
app.blueprint(bpv2)
with pytest.raises(RouteExists, match="Route already registered*"):
@bpv3.route("/")
async def handler(request: Request):
return text("v3")
app.blueprint(bpv3)
def test_bp_copy_with_route_overwriting(app: Sanic):
bpv1 = Blueprint("bp_v1", version=1, url_prefix="my_api")
@bpv1.route("/")
async def handler(request: Request):
return text("v1")
app.blueprint(bpv1)
bpv2 = bpv1.copy("bp_v2", version=2, allow_route_overwrite=True)
bpv3 = bpv1.copy(
"bp_v3", version=3, allow_route_overwrite=True, with_registration=False
)
@bpv2.route("/")
async def handler(request: Request):
return text("v2")
app.blueprint(bpv2)
@bpv3.route("/")
async def handler(request: Request):
return text("v3")
app.blueprint(bpv3)
_, response = app.test_client.get("/v1/my_api")
assert response.status == 200
assert response.text == "v1"
_, response = app.test_client.get("/v2/my_api")
assert response.status == 200
assert response.text == "v2"
_, response = app.test_client.get("/v3/my_api")
assert response.status == 200
assert response.text == "v3"

View File

@@ -1,3 +1,5 @@
import pytest
from pytest import raises
from sanic.app import Sanic
@@ -340,3 +342,40 @@ def test_nested_bp_group_properties():
routes = [route.path for route in app.router.routes]
assert routes == ["three/one/four"]
@pytest.mark.asyncio
async def test_multiple_nested_bp_group():
bp1 = Blueprint("bp1", url_prefix="/bp1")
bp2 = Blueprint("bp2", url_prefix="/bp2")
bp1.add_route(lambda _: ..., "/", name="route1")
bp2.add_route(lambda _: ..., "/", name="route2")
group_a = Blueprint.group(
bp1, bp2, url_prefix="/group-a", name_prefix="group-a"
)
group_b = Blueprint.group(
bp1, bp2, url_prefix="/group-b", name_prefix="group-b"
)
app = Sanic("PropTest")
app.blueprint(group_a)
app.blueprint(group_b)
await app._startup()
routes = [route.path for route in app.router.routes]
assert routes == [
"group-a/bp1",
"group-a/bp2",
"group-b/bp1",
"group-b/bp2",
]
names = [route.name for route in app.router.routes]
assert names == [
"PropTest.group-a_bp1.route1",
"PropTest.group-a_bp2.route2",
"PropTest.group-b_bp1.route1",
"PropTest.group-b_bp2.route2",
]

View File

@@ -527,3 +527,26 @@ def test_guess_mime_logging(
]
assert logmsg == expected
@pytest.mark.parametrize(
"format,expected",
(
("html", "text/html; charset=utf-8"),
("text", "text/plain; charset=utf-8"),
("json", "application/json"),
),
)
def test_exception_header_on_renderers(app: Sanic, format, expected):
app.config.FALLBACK_ERROR_FORMAT = format
@app.get("/test")
def test(request):
raise SanicException(
"test", status_code=400, headers={"exception": "test"}
)
_, response = app.test_client.get("/test")
assert response.status == 400
assert response.headers.get("exception") == "test"
assert response.content_type == expected

View File

@@ -23,6 +23,7 @@ from sanic.compat import Header
from sanic.cookies import CookieJar
from sanic.response import (
HTTPResponse,
ResponseStream,
empty,
file,
file_stream,
@@ -943,3 +944,17 @@ def test_file_validating_304_response(
)
assert response.status == 304
assert response.body == b""
def test_stream_response_with_default_headers(app: Sanic):
async def sample_streaming_fn(response_):
await response_.write("foo")
@app.route("/")
async def test(request: Request):
return ResponseStream(sample_streaming_fn, content_type="text/csv")
_, response = app.test_client.get("/")
assert response.text == "foo"
assert response.headers["Transfer-Encoding"] == "chunked"
assert response.headers["Content-Type"] == "text/csv"

View File

@@ -213,3 +213,12 @@ def test_pop_list(json_app: Sanic):
_, resp = json_app.test_client.get("/json-pop")
assert resp.body == json_dumps(["b"]).encode()
def test_json_response_class_sets_proper_content_type(json_app: Sanic):
@json_app.get("/json-class")
async def handler(request: Request):
return JSONResponse(JSON_BODY)
_, resp = json_app.test_client.get("/json-class")
assert resp.headers["content-type"] == "application/json"

View File

@@ -1,18 +1,19 @@
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
from sanic.compat import ctrlc_workaround_for_windows
from sanic.exceptions import BadRequest
from sanic.exceptions import BadRequest, ServerError
from sanic.response import HTTPResponse
from sanic.signals import Event
async def stop(app, loop):
@@ -148,3 +149,26 @@ def test_signals_with_invalid_invocation(app):
BadRequest, match="Invalid event registration: Missing event name"
):
app.listener(stop)
def test_signal_server_lifecycle_exception(app: Sanic):
trigger: Optional[Exception] = None
@app.route("/hello")
async def hello_route(request):
return HTTPResponse()
@app.signal(Event.SERVER_LIFECYCLE_EXCEPTION)
async def test_signal(exception: Exception):
nonlocal trigger
trigger = exception
@app.before_server_start
async def test_before_server_start(app):
raise ServerError("test_before_server_start")
with pytest.raises(ServerError, match="test_before_server_start"):
app.run(single_process=True)
assert isinstance(trigger, ServerError)
assert str(trigger) == "test_before_server_start"

View File

@@ -101,6 +101,31 @@ def test_static_file_pathlib(app, static_file_directory, file_name):
assert response.body == get_file_content(static_file_directory, file_name)
@pytest.mark.parametrize(
"file_name",
[
"test.file",
"decode me.txt",
"python.png",
"symlink",
"hard_link",
],
)
def test_static_file_pathlib_relative_path_traversal(
app, static_file_directory, file_name
):
"""Get the current working directory and check if it ends with "sanic" """
cwd = Path.cwd()
if not str(cwd).endswith("sanic"):
pytest.skip("Current working directory does not end with 'sanic'")
file_path = "./tests/static/../static/"
app.static("/", file_path)
_, response = app.test_client.get(f"/{file_name}")
assert response.status == 200
assert response.body == get_file_content(static_file_directory, file_name)
@pytest.mark.parametrize(
"file_name",
[b"test.file", b"decode me.txt", b"python.png"],