Compare commits
15 Commits
v23.3.0
...
start-rest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa864f0bab | ||
|
|
976da69e79 | ||
|
|
11a0b15194 | ||
|
|
c21999a248 | ||
|
|
c17230ef94 | ||
|
|
049983cb70 | ||
|
|
e374409567 | ||
|
|
4068a0d83d | ||
|
|
70da5e9879 | ||
|
|
f48506d620 | ||
|
|
f2cc83c1ba | ||
|
|
273825dab6 | ||
|
|
9a7dafd531 | ||
|
|
50117d174c | ||
|
|
af67801062 |
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/coverage.yml
vendored
2
.github/workflows/coverage.yml
vendored
@@ -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:
|
||||
|
||||
1
.github/workflows/pr-bandit.yml
vendored
1
.github/workflows/pr-bandit.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-docs.yml
vendored
1
.github/workflows/pr-docs.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-linter.yml
vendored
1
.github/workflows/pr-linter.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-python310.yml
vendored
1
.github/workflows/pr-python310.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-python311.yml
vendored
1
.github/workflows/pr-python311.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-python37.yml
vendored
1
.github/workflows/pr-python37.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-python38.yml
vendored
1
.github/workflows/pr-python38.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-python39.yml
vendored
1
.github/workflows/pr-python39.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-type-check.yml
vendored
1
.github/workflows/pr-type-check.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-windows.yml
vendored
1
.github/workflows/pr-windows.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,4 +21,5 @@ dist/*
|
||||
pip-wheel-metadata/
|
||||
.pytest_cache/*
|
||||
.venv/*
|
||||
venv/*
|
||||
.vscode/*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[build-system]
|
||||
requires = ["setuptools<60.0", "wheel"]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.black]
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "23.3.0"
|
||||
__version__ = "23.3.1"
|
||||
|
||||
31
sanic/app.py
31
sanic/app.py
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
|
||||
from os import environ
|
||||
|
||||
from sanic.compat import is_atty
|
||||
from sanic.helpers import is_atty
|
||||
|
||||
|
||||
BASE_LOGO = """
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -16,3 +16,5 @@ class ProcessState(IntEnum):
|
||||
ACKED = auto()
|
||||
JOINED = auto()
|
||||
TERMINATED = auto()
|
||||
FAILED = auto()
|
||||
COMPLETED = auto()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user