Compare commits
	
		
			15 Commits
		
	
	
		
			current-re
			...
			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