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: push:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
schedule: schedule:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@@ -25,5 +25,5 @@ def key_exist_handler(request):
return text("num does not exist in request") return text("num does not exist in request")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True) 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.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(blueprint2)
app.blueprint(blueprint3) 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_running = False
app.is_stopping = True app.is_stopping = True
if __name__ == "__main__":
https.run(port=HTTPS_PORT, debug=True) https.run(port=HTTPS_PORT, debug=True)

View File

@@ -39,4 +39,5 @@ async def test(request):
return json(response) 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") 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): for i in range(1, 250000):
data += str(i) 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) print(r.text)

View File

@@ -20,4 +20,5 @@ def timeout(request, exception):
return response.text("RequestTimeout from error_handler.", 408) 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): async def test(request):
return response.json({"answer": "42"}) 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()) # When using app.run(), this actually triggers before the serv_coro.
serv_coro = app.create_server( # But, in this example, we are using the convenience method, even if it is
host="0.0.0.0", port=8000, return_asyncio_server=True # out of order.
) loop.run_until_complete(server.before_start())
loop = asyncio.get_event_loop() loop.run_until_complete(server.after_start())
serv_task = asyncio.ensure_future(serv_coro, loop=loop) try:
signal(SIGINT, lambda s, f: loop.stop()) loop.run_forever()
server: AsyncioServer = loop.run_until_complete(serv_task) except KeyboardInterrupt:
loop.run_until_complete(server.startup()) loop.stop()
finally:
loop.run_until_complete(server.before_stop())
# When using app.run(), this actually triggers before the serv_coro. # Wait for server to close
# But, in this example, we are using the convenience method, even if it is close_task = server.close()
# out of order. loop.run_until_complete(close_task)
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 # Complete all tasks on the loop
close_task = server.close() for connection in server.connections:
loop.run_until_complete(close_task) 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] [build-system]
requires = ["setuptools<60.0", "wheel"] requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.black] [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 logging.config
import re import re
import sys import sys
from asyncio import ( from asyncio import (
AbstractEventLoop, AbstractEventLoop,
CancelledError, CancelledError,
@@ -55,12 +54,7 @@ from sanic.blueprint_group import BlueprintGroup
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support
from sanic.config import SANIC_PREFIX, Config from sanic.config import SANIC_PREFIX, Config
from sanic.exceptions import ( from sanic.exceptions import BadRequest, SanicException, ServerError, URLBuildError
BadRequest,
SanicException,
ServerError,
URLBuildError,
)
from sanic.handlers import ErrorHandler from sanic.handlers import ErrorHandler
from sanic.helpers import Default, _default from sanic.helpers import Default, _default
from sanic.http import Stage from sanic.http import Stage
@@ -90,7 +84,6 @@ from sanic.worker.inspector import Inspector
from sanic.worker.loader import CertLoader from sanic.worker.loader import CertLoader
from sanic.worker.manager import WorkerManager from sanic.worker.manager import WorkerManager
if TYPE_CHECKING: if TYPE_CHECKING:
try: try:
from sanic_ext import Extend # type: ignore from sanic_ext import Extend # type: ignore
@@ -417,8 +410,11 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
def _apply_listener(self, listener: FutureListener): def _apply_listener(self, listener: FutureListener):
return self.register_listener(listener.listener, listener.event) 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 = route._asdict()
params["overwrite"] = overwrite
websocket = params.pop("websocket", False) websocket = params.pop("websocket", False)
subprotocols = params.pop("subprotocols", None) subprotocols = params.pop("subprotocols", None)
@@ -550,6 +546,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
) )
else: else:
params["version_prefix"] = blueprint.version_prefix 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) self.blueprint(item, **params)
return return
if blueprint.name in self.blueprints: if blueprint.name in self.blueprints:
@@ -767,6 +766,10 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
:raises ServerError: response 500 :raises ServerError: response 500
""" """
response = None response = None
await self.dispatch(
"server.lifecycle.exception",
context={"exception": exception},
)
await self.dispatch( await self.dispatch(
"http.lifecycle.exception", "http.lifecycle.exception",
inline=True, inline=True,
@@ -1666,7 +1669,10 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
def inspector(self): def inspector(self):
if environ.get("SANIC_WORKER_PROCESS") or not self._inspector: if environ.get("SANIC_WORKER_PROCESS") or not self._inspector:
raise SanicException( 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 return self._inspector
@@ -1674,6 +1680,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
def manager(self): def manager(self):
if environ.get("SANIC_WORKER_PROCESS") or not self._manager: if environ.get("SANIC_WORKER_PROCESS") or not self._manager:
raise SanicException( 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 return self._manager

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import os import os
import platform
import signal import signal
import sys import sys
@@ -10,6 +11,7 @@ from typing import Awaitable, Union
from multidict import CIMultiDict # type: ignore from multidict import CIMultiDict # type: ignore
from sanic.helpers import Default from sanic.helpers import Default
from sanic.log import error_logger
if sys.version_info < (3, 8): # no cov if sys.version_info < (3, 8): # no cov
@@ -22,6 +24,7 @@ else: # no cov
] ]
OS_IS_WINDOWS = os.name == "nt" OS_IS_WINDOWS = os.name == "nt"
PYPY_IMPLEMENTATION = platform.python_implementation() == "PyPy"
UVLOOP_INSTALLED = False UVLOOP_INSTALLED = False
try: try:
@@ -73,6 +76,38 @@ def enable_windows_color_support():
kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7) 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): class Header(CIMultiDict):
""" """
Container used for both request and response headers. It is a subclass of 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>`_ <https://multidict.readthedocs.io/en/stable/multidict.html#multidict>`_
for more details about how to use the object. In general, it should work for more details about how to use the object. In general, it should work
very similar to a regular dictionary. very similar to a regular dictionary.
""" """ # noqa: E501
def __getattr__(self, key: str) -> str: def __getattr__(self, key: str) -> str:
if key.startswith("_"): if key.startswith("_"):
@@ -112,6 +147,12 @@ if use_trio: # pragma: no cover
open_async = trio.open_file open_async = trio.open_file
CancelledErrors = tuple([asyncio.CancelledError, trio.Cancelled]) CancelledErrors = tuple([asyncio.CancelledError, trio.Cancelled])
else: 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 import open as aio_open # type: ignore
from aiofiles.os import stat as stat_async # type: ignore # noqa: F401 from aiofiles.os import stat as stat_async # type: ignore # noqa: F401
@@ -143,7 +184,3 @@ def ctrlc_workaround_for_windows(app):
die = False die = False
signal.signal(signal.SIGINT, ctrlc_handler) signal.signal(signal.SIGINT, ctrlc_handler)
app.add_task(stay_active) 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 self.full
if self.debug and not getattr(self.exception, "quiet", False) if self.debug and not getattr(self.exception, "quiet", False)
else self.minimal else self.minimal
) )()
return output() output.status = self.status
output.headers.update(self.headers)
return output
def minimal(self) -> HTTPResponse: # noqa def minimal(self) -> HTTPResponse: # noqa
""" """
@@ -125,7 +127,7 @@ class HTMLRenderer(BaseRenderer):
request=self.request, request=self.request,
exc=self.exception, exc=self.exception,
) )
return html(page.render(), status=self.status, headers=self.headers) return html(page.render())
def minimal(self) -> HTTPResponse: def minimal(self) -> HTTPResponse:
return self.full() return self.full()
@@ -146,8 +148,7 @@ class TextRenderer(BaseRenderer):
text=self.text, text=self.text,
bar=("=" * len(self.title)), bar=("=" * len(self.title)),
body=self._generate_body(full=True), body=self._generate_body(full=True),
), )
status=self.status,
) )
def minimal(self) -> HTTPResponse: def minimal(self) -> HTTPResponse:
@@ -157,9 +158,7 @@ class TextRenderer(BaseRenderer):
text=self.text, text=self.text,
bar=("=" * len(self.title)), bar=("=" * len(self.title)),
body=self._generate_body(full=False), body=self._generate_body(full=False),
), )
status=self.status,
headers=self.headers,
) )
@property @property
@@ -218,11 +217,11 @@ class JSONRenderer(BaseRenderer):
def full(self) -> HTTPResponse: def full(self) -> HTTPResponse:
output = self._generate_output(full=True) 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: def minimal(self) -> HTTPResponse:
output = self._generate_output(full=False) 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): def _generate_output(self, *, full):
output = { output = {

View File

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

View File

@@ -1,5 +1,7 @@
"""Defines basics of HTTP standard.""" """Defines basics of HTTP standard."""
import sys
from importlib import import_module from importlib import import_module
from inspect import ismodule from inspect import ismodule
from typing import Dict from typing import Dict
@@ -157,6 +159,10 @@ def import_string(module_name, package=None):
return obj() return obj()
def is_atty() -> bool:
return bool(sys.stdout and sys.stdout.isatty())
class Default: class Default:
""" """
It is used to replace `None` or `object()` as a sentinel 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 typing import TYPE_CHECKING, Any, Dict
from warnings import warn 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. # 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, error_format,
route_context, 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) self._future_routes.add(route)
args = list(signature(handler).parameters.keys()) args = list(signature(handler).parameters.keys())
@@ -182,7 +186,7 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
handler.is_stream = stream handler.is_stream = stream
if apply: if apply:
self._apply_route(route) self._apply_route(route, overwrite=overwrite)
if static: if static:
return route, handler 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.motd import MOTD
from sanic.application.state import ApplicationServerInfo, Mode, ServerStage from sanic.application.state import ApplicationServerInfo, Mode, ServerStage
from sanic.base.meta import SanicMeta 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.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.constants import HTTP
from sanic.http.tls import get_ssl_context, process_to_context from sanic.http.tls import get_ssl_context, process_to_context
from sanic.http.tls.context import SanicSSLContext from sanic.http.tls.context import SanicSSLContext

View File

@@ -95,7 +95,7 @@ class StaticMixin(BaseMixin, metaclass=SanicMeta):
) )
try: try:
file_or_directory = Path(file_or_directory) file_or_directory = Path(file_or_directory).resolve()
except TypeError: except TypeError:
raise TypeError( raise TypeError(
"Static file or directory must be a path-like object or string" "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 import sanic
from sanic.request import Request from sanic import request
from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.response import BaseHTTPResponse, HTTPResponse
Sanic = TypeVar("Sanic", bound="sanic.Sanic") Sanic = TypeVar("Sanic", bound="sanic.Sanic")
Request = TypeVar("Request", bound="request.Request")
MiddlewareResponse = Union[ MiddlewareResponse = Union[
Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]] Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]]

View File

@@ -38,7 +38,9 @@ else:
try: 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: except ImportError:
# This is done in order to ensure that the JSON response is # This is done in order to ensure that the JSON response is
# kept consistent across both ujson and inbuilt json usage. # kept consistent across both ujson and inbuilt json usage.
@@ -345,7 +347,7 @@ class JSONResponse(HTTPResponse):
body: Optional[Any] = None, body: Optional[Any] = None,
status: int = 200, status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None, headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None, content_type: str = "application/json",
dumps: Optional[Callable[..., str]] = None, dumps: Optional[Callable[..., str]] = None,
**kwargs: Any, **kwargs: Any,
): ):
@@ -520,7 +522,9 @@ class ResponseStream:
headers: Optional[Union[Header, Dict[str, str]]] = None, headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[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) headers = Header(headers)
self.streaming_fn = streaming_fn self.streaming_fn = streaming_fn
self.status = status self.status = status

View File

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

View File

@@ -20,6 +20,7 @@ class Event(Enum):
SERVER_INIT_BEFORE = "server.init.before" SERVER_INIT_BEFORE = "server.init.before"
SERVER_SHUTDOWN_AFTER = "server.shutdown.after" SERVER_SHUTDOWN_AFTER = "server.shutdown.after"
SERVER_SHUTDOWN_BEFORE = "server.shutdown.before" SERVER_SHUTDOWN_BEFORE = "server.shutdown.before"
SERVER_LIFECYCLE_EXCEPTION = "server.lifecycle.exception"
HTTP_LIFECYCLE_BEGIN = "http.lifecycle.begin" HTTP_LIFECYCLE_BEGIN = "http.lifecycle.begin"
HTTP_LIFECYCLE_COMPLETE = "http.lifecycle.complete" HTTP_LIFECYCLE_COMPLETE = "http.lifecycle.complete"
HTTP_LIFECYCLE_EXCEPTION = "http.lifecycle.exception" HTTP_LIFECYCLE_EXCEPTION = "http.lifecycle.exception"
@@ -43,6 +44,7 @@ RESERVED_NAMESPACES = {
Event.SERVER_INIT_BEFORE.value, Event.SERVER_INIT_BEFORE.value,
Event.SERVER_SHUTDOWN_AFTER.value, Event.SERVER_SHUTDOWN_AFTER.value,
Event.SERVER_SHUTDOWN_BEFORE.value, Event.SERVER_SHUTDOWN_BEFORE.value,
Event.SERVER_LIFECYCLE_EXCEPTION.value,
), ),
"http": ( "http": (
Event.HTTP_LIFECYCLE_BEGIN.value, Event.HTTP_LIFECYCLE_BEGIN.value,
@@ -168,6 +170,16 @@ class SignalRouter(BaseRouter):
elif maybe_coroutine: elif maybe_coroutine:
return maybe_coroutine return maybe_coroutine
return None 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: finally:
for signal_event in events: for signal_event in events:
signal_event.clear() signal_event.clear()

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import os import os
from contextlib import suppress from contextlib import suppress
from itertools import count from enum import IntEnum, auto
from itertools import chain, count
from random import choice from random import choice
from signal import SIGINT, SIGTERM, Signals from signal import SIGINT, SIGTERM, Signals
from signal import signal as signal_func 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.compat import OS_IS_WINDOWS
from sanic.exceptions import ServerKilled 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.constants import RestartOrder
from sanic.worker.process import ProcessState, Worker, WorkerProcess from sanic.worker.process import ProcessState, Worker, WorkerProcess
if not OS_IS_WINDOWS: if not OS_IS_WINDOWS:
from signal import SIGKILL from signal import SIGKILL
else: else:
SIGKILL = SIGINT SIGKILL = SIGINT
class MonitorCycle(IntEnum):
BREAK = auto()
CONTINUE = auto()
class WorkerManager: class WorkerManager:
THRESHOLD = WorkerProcess.THRESHOLD THRESHOLD = WorkerProcess.THRESHOLD
MAIN_IDENT = "Sanic-Main" MAIN_IDENT = "Sanic-Main"
@@ -60,6 +64,8 @@ class WorkerManager:
func: Callable[..., Any], func: Callable[..., Any],
kwargs: Dict[str, Any], kwargs: Dict[str, Any],
transient: bool = False, transient: bool = False,
restartable: Optional[bool] = None,
tracked: bool = True,
workers: int = 1, workers: int = 1,
) -> Worker: ) -> Worker:
""" """
@@ -75,14 +81,35 @@ class WorkerManager:
then the Worker Manager will restart the process along then the Worker Manager will restart the process along
with any global restart (ex: auto-reload), defaults to False with any global restart (ex: auto-reload), defaults to False
:type transient: bool, optional :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 :param workers: The number of worker processes to run, defaults to 1
:type workers: int, optional :type workers: int, optional
:return: The Worker instance :return: The Worker instance
:rtype: Worker :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 container = self.transient if transient else self.durable
worker = Worker( 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 container[worker.ident] = worker
return worker return worker
@@ -94,6 +121,7 @@ class WorkerManager:
self._serve, self._serve,
self._server_settings, self._server_settings,
transient=True, transient=True,
restartable=True,
) )
def shutdown_server(self, ident: Optional[str] = None) -> None: def shutdown_server(self, ident: Optional[str] = None) -> None:
@@ -153,9 +181,32 @@ class WorkerManager:
restart_order=RestartOrder.SHUTDOWN_FIRST, restart_order=RestartOrder.SHUTDOWN_FIRST,
**kwargs, **kwargs,
): ):
restarted = set()
for process in self.transient_processes: 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) 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): def scale(self, num_worker: int):
if num_worker <= 0: if num_worker <= 0:
@@ -183,45 +234,13 @@ class WorkerManager:
self.wait_for_ack() self.wait_for_ack()
while True: while True:
try: try:
if self.monitor_subscriber.poll(0.1): cycle = self._poll_monitor()
message = self.monitor_subscriber.recv() if cycle is MonitorCycle.BREAK:
logger.debug( break
f"Monitor message: {message}", extra={"verbosity": 2} elif cycle is MonitorCycle.CONTINUE:
) continue
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,
)
self._sync_states() self._sync_states()
self._cleanup_non_tracked_workers()
except InterruptedError: except InterruptedError:
if not OS_IS_WINDOWS: if not OS_IS_WINDOWS:
raise raise
@@ -264,6 +283,10 @@ class WorkerManager:
def workers(self) -> List[Worker]: def workers(self) -> List[Worker]:
return list(self.transient.values()) + list(self.durable.values()) 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 @property
def processes(self): def processes(self):
for worker in self.workers: for worker in self.workers:
@@ -276,6 +299,12 @@ class WorkerManager:
for process in worker.processes: for process in worker.processes:
yield process yield process
@property
def durable_processes(self):
for worker in self.durable.values():
for process in worker.processes:
yield process
def kill(self): def kill(self):
for process in self.processes: for process in self.processes:
logger.info("Killing %s [%s]", process.name, process.pid) logger.info("Killing %s [%s]", process.name, process.pid)
@@ -298,6 +327,25 @@ class WorkerManager:
process.terminate() process.terminate()
self._shutting_down = True 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 @property
def pid(self): def pid(self):
return os.getpid() return os.getpid()
@@ -317,5 +365,97 @@ class WorkerManager:
except KeyError: except KeyError:
process.set_state(ProcessState.TERMINATED, True) process.set_state(ProcessState.TERMINATED, True)
continue continue
if not process.is_alive():
state = "FAILED" if process.exitcode else "COMPLETED"
if state and process.state.name != state: if state and process.state.name != state:
process.set_state(ProcessState[state], True) 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 multiprocessing.connection import Connection
from os import environ, getpid 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.log import Colors, logger
from sanic.worker.process import ProcessState from sanic.worker.process import ProcessState
@@ -28,6 +28,27 @@ class WorkerMultiplexer:
"state": ProcessState.ACKED.name, "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( def restart(
self, self,
name: str = "", name: str = "",

View File

@@ -1,5 +1,4 @@
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from multiprocessing.context import BaseContext from multiprocessing.context import BaseContext
from signal import SIGINT from signal import SIGINT
@@ -20,13 +19,22 @@ class WorkerProcess:
THRESHOLD = 300 # == 30 seconds THRESHOLD = 300 # == 30 seconds
SERVER_LABEL = "Server" 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.state = ProcessState.IDLE
self.factory = factory self.factory = factory
self.name = name self.name = name
self.target = target self.target = target
self.kwargs = kwargs self.kwargs = kwargs
self.worker_state = worker_state self.worker_state = worker_state
self.restartable = restartable
if self.name not in self.worker_state: if self.name not in self.worker_state:
self.worker_state[self.name] = { self.worker_state[self.name] = {
"server": self.SERVER_LABEL in self.name "server": self.SERVER_LABEL in self.name
@@ -132,6 +140,10 @@ class WorkerProcess:
def pid(self): def pid(self):
return self._current_process.pid return self._current_process.pid
@property
def exitcode(self):
return self._current_process.exitcode
def _terminate_now(self): def _terminate_now(self):
logger.debug( logger.debug(
f"{Colors.BLUE}Begin restart termination: " f"{Colors.BLUE}Begin restart termination: "
@@ -193,6 +205,8 @@ class Worker:
context: BaseContext, context: BaseContext,
worker_state: Dict[str, Any], worker_state: Dict[str, Any],
num: int = 1, num: int = 1,
restartable: bool = False,
tracked: bool = True,
): ):
self.ident = ident self.ident = ident
self.num = num self.num = num
@@ -201,6 +215,8 @@ class Worker:
self.server_settings = server_settings self.server_settings = server_settings
self.worker_state = worker_state self.worker_state = worker_state
self.processes: Set[WorkerProcess] = set() self.processes: Set[WorkerProcess] = set()
self.restartable = restartable
self.tracked = tracked
for _ in range(num): for _ in range(num):
self.create_process() self.create_process()
@@ -215,6 +231,10 @@ class Worker:
target=self.serve, target=self.serve,
kwargs={**self.server_settings}, kwargs={**self.server_settings},
worker_state=self.worker_state, worker_state=self.worker_state,
restartable=self.restartable,
) )
self.processes.add(process) self.processes.add(process)
return 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 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_bp4.handle_request" in route_names
assert "test_bp_copy.test_bp5.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 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 pytest import raises
from sanic.app import Sanic from sanic.app import Sanic
@@ -340,3 +342,40 @@ def test_nested_bp_group_properties():
routes = [route.path for route in app.router.routes] routes = [route.path for route in app.router.routes]
assert routes == ["three/one/four"] 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 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.cookies import CookieJar
from sanic.response import ( from sanic.response import (
HTTPResponse, HTTPResponse,
ResponseStream,
empty, empty,
file, file,
file_stream, file_stream,
@@ -943,3 +944,17 @@ def test_file_validating_304_response(
) )
assert response.status == 304 assert response.status == 304
assert response.body == b"" 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") _, resp = json_app.test_client.get("/json-pop")
assert resp.body == json_dumps(["b"]).encode() 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 asyncio
import os import os
import signal import signal
from queue import Queue from queue import Queue
from types import SimpleNamespace from types import SimpleNamespace
from typing import Optional
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from sanic_testing.testing import HOST, PORT from sanic_testing.testing import HOST, PORT
from sanic import Sanic
from sanic.compat import ctrlc_workaround_for_windows 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.response import HTTPResponse
from sanic.signals import Event
async def stop(app, loop): async def stop(app, loop):
@@ -148,3 +149,26 @@ def test_signals_with_invalid_invocation(app):
BadRequest, match="Invalid event registration: Missing event name" BadRequest, match="Invalid event registration: Missing event name"
): ):
app.listener(stop) 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) 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( @pytest.mark.parametrize(
"file_name", "file_name",
[b"test.file", b"decode me.txt", b"python.png"], [b"test.file", b"decode me.txt", b"python.png"],