Compare commits

...

39 Commits

Author SHA1 Message Date
Adam Hopkins
f83553be9e Remove unused import 2022-12-12 15:12:47 +02:00
Adam Hopkins
0c3527b8b2 Cleanup typing 2022-12-12 12:20:55 +02:00
Adam Hopkins
232bbce1e0 Cleanup tests 2022-12-12 12:04:12 +02:00
Adam Hopkins
f034a31d29 Reorganize tests 2022-12-12 11:44:42 +02:00
Adam Hopkins
3536c0af27 Fix caps on worker prefix 2022-12-12 11:29:02 +02:00
Adam Hopkins
0e7ee94574 Add test 2022-12-12 11:26:29 +02:00
Adam Hopkins
4e4e2b036b Merge branch 'main' of github.com:sanic-org/sanic into monitor-restart 2022-12-12 09:41:09 +02:00
Adam Hopkins
92e7463721 Add a restart mechanism to all workers in the multiplexer (#2622) 2022-12-11 11:33:42 +02:00
Adam Hopkins
7f682cea02 Add ordering config 2022-12-11 10:59:30 +02:00
Adam Hopkins
ae1669cd8f Merge branch 'main' of github.com:sanic-org/sanic into monitor-restart 2022-12-11 10:38:30 +02:00
Néstor Pérez
8e720365c2 Add JSONResponse class (#2569)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2022-12-11 10:37:45 +02:00
Adam Hopkins
3c4c136090 Wait for new process to ACK before termination of old on restart 2022-12-08 14:46:45 +02:00
Adam Hopkins
d4041161c7 Ensure middleware executes once per request timeout (#2615) 2022-12-07 23:07:17 +02:00
Adam Hopkins
f32437bf13 Kill server early on worker error (#2610) 2022-12-07 14:42:17 +02:00
LiraNuna
0909e94527 Corrected Colors enum under Python 3.11 (#2590)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
Fixes https://github.com/sanic-org/sanic/issues/2589
2022-11-29 12:17:48 +02:00
Adam Hopkins
aef2673c38 Force socket shutdown before close (#2607)
Co-authored-by: Zhiwei <zhi.wei.liang@outlook.com>
2022-11-29 12:04:22 +02:00
Aymeric Augustin
4c14910d5b Add compatibility with websockets 11.0. (#2609)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2022-11-29 11:45:18 +02:00
Adam Hopkins
beae35f921 Ignore recent failures on bad TLS tests (#2611) 2022-11-29 10:51:51 +02:00
Zhiwei
ad4e526c77 Require uvloop >= 0.15.0 (#2598) 2022-11-13 15:32:04 +02:00
Adam Hopkins
4422d0c34d Mergeback from current-release 2022-10-31 13:24:47 +02:00
Adam Hopkins
ad9183d21d Merge branch 'main' of github.com:sanic-org/sanic into current-release 2022-10-31 13:22:47 +02:00
Adam Hopkins
d70636ba2e Add GenericCreator for loading SSL certs in processes (#2578) 2022-10-31 13:22:30 +02:00
Adam Hopkins
da23f85675 Set version 2022-10-31 13:20:17 +02:00
Adam Hopkins
3f4663b9f8 Resolve edge case in nested BP Groups (#2592) 2022-10-31 12:58:41 +02:00
Adam Hopkins
65d7447cf6 Add interval sleep in reloader (#2595) 2022-10-31 12:34:01 +02:00
Adam Hopkins
5369291c27 22.9 Docs (#2556) 2022-10-31 11:47:23 +02:00
Ryu Juheon
1c4925edf7 fix: sideeffects created by changing fork to spawn (#2591) 2022-10-27 20:39:17 +03:00
Santi Cardozo
6b9edfd05c improve error message if no apps found in registry (#2585) 2022-10-25 16:54:44 +03:00
Adam Hopkins
97f33f42df Update SECURITY.md 2022-10-25 13:05:13 +03:00
Adam Hopkins
15a588a90c Upgrade markdown templates to issue forms (#2588) 2022-10-25 13:04:11 +03:00
Ryu Juheon
82421e7efc docs: sanic now supports windows. (#2582) 2022-10-21 14:31:22 +03:00
Adam Hopkins
f891995b48 Start v22.12 2022-09-29 13:04:46 +03:00
Adam Hopkins
5052321801 Remove deprecated items (#2555) 2022-09-29 01:07:09 +03:00
Adam Hopkins
23ce4eaaa4 Merge branch 'main' of github.com:sanic-org/sanic 2022-09-23 00:16:27 +03:00
Adam Hopkins
23a430c4ad Set version properly 2022-09-23 00:16:10 +03:00
Adam Hopkins
ec158ffa69 Additional logger and support for multiprocess manager (#2551) 2022-09-23 00:01:33 +03:00
Adam Hopkins
6e32270036 Begin middleware revamp (#2550) 2022-09-22 00:43:42 +03:00
Zhiwei
43ba381e7b Refactor _static_request_handler (#2533)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2022-09-21 00:45:03 +03:00
Zhiwei
16503319e5 Make WebsocketImplProtocol async iterable (#2490) 2022-09-21 00:20:32 +03:00
64 changed files with 2112 additions and 861 deletions

66
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: 🐞 Bug report
description: Create a report to help us improve
labels: ["bug", "triage"]
body:
- type: checkboxes
id: existing
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is, make sure to paste any exceptions and tracebacks using markdown code-block syntax to make it easier to read.
validations:
required: true
- type: textarea
id: code
attributes:
label: Code snippet
description: Relevant source code, make sure to remove what is not necessary.
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: dropdown
id: running
attributes:
label: How do you run Sanic?
options:
- Sanic CLI
- As a module
- As a script (`app.run` or `Sanic.serve`)
- ASGI
validations:
required: true
- type: input
id: os
attributes:
label: Operating System
description: What OS?
validations:
required: true
- type: input
id: version
attributes:
label: Sanic Version
description: Check startup logs or try `sanic --version`
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false

View File

@@ -1,27 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
labels: ["bug"]
---
**Describe the bug**
<!-- A clear and concise description of what the bug is, make sure to paste any exceptions and tracebacks. -->
**Code snippet**
<!-- Relevant source code, make sure to remove what is not necessary. -->
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Environment (please complete the following information):**
<!-- Please provide the information below. Instead, you can copy and paste the message that Sanic shows on startup. If you do, please remember to format it with ``` -->
- OS:
- Sanic Version:
**Additional context**
<!-- Add any other context about the problem here. -->

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Questions and Help
url: https://community.sanicframework.org/c/questions-and-help

View File

@@ -0,0 +1,34 @@
name: 🌟 Feature request
description: Suggest an enhancement for Sanic
labels: ["feature request"]
body:
- type: checkboxes
id: existing
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the enhancement you are proposing.
options:
- label: I have searched the existing issues
required: true
- type: textarea
id: description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: false
- type: textarea
id: code
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false

View File

@@ -1,17 +0,0 @@
---
name: Feature request
about: Suggest an idea for Sanic
labels: ["feature request"]
---
**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Additional context**
<!-- Add any other context or sample code about the feature request here. -->

View File

@@ -102,9 +102,6 @@ Installation
If you are running on a clean install of Fedora 28 or above, please make sure you have the ``redhat-rpm-config`` package installed in case if you want to
use ``sanic`` with ``ujson`` dependency.
.. note::
Windows support is currently "experimental" and on a best-effort basis. Multiple workers are also not currently supported on Windows (see `Issue #1517 <https://github.com/sanic-org/sanic/issues/1517>`_), but setting ``workers=1`` should launch the server successfully.
Hello World Example
-------------------

View File

@@ -7,7 +7,8 @@ Sanic releases long term support release once a year in December. LTS releases r
| Version | LTS | Supported |
| ------- | ------------- | ----------------------- |
| 22.6 | | :white_check_mark: |
| 22.9 | | :white_check_mark: |
| 22.6 | | :x: |
| 22.3 | | :x: |
| 21.12 | until 2023-12 | :white_check_mark: |
| 21.9 | | :x: |

View File

@@ -2,3 +2,12 @@
.wy-nav-top {
background: #444444;
}
#changelog section {
padding-left: 3rem;
}
#changelog section h2,
#changelog section h3 {
margin-left: -3rem;
}

View File

@@ -1,6 +1,7 @@
📜 Changelog
============
.. mdinclude:: ./releases/22/22.9.md
.. mdinclude:: ./releases/22/22.6.md
.. mdinclude:: ./releases/22/22.3.md
.. mdinclude:: ./releases/21/21.12.md

View File

@@ -1,6 +1,17 @@
## Version 22.6.0 🔶
## Version 22.6.2
_Current version_
### Bugfixes
- [#2522](https://github.com/sanic-org/sanic/pull/2522) Always show server location in ASGI
## Version 22.6.1
### Bugfixes
- [#2477](https://github.com/sanic-org/sanic/pull/2477) Sanic static directory fails when folder name ends with ".."
## Version 22.6.0
### Features
- [#2378](https://github.com/sanic-org/sanic/pull/2378) Introduce HTTP/3 and autogeneration of TLS certificates in `DEBUG` mode

View File

@@ -0,0 +1,47 @@
## Version 22.9.0 🔶
_Current version_
### Features
- [#2445](https://github.com/sanic-org/sanic/pull/2445) Add custom loads function
- [#2490](https://github.com/sanic-org/sanic/pull/2490) Make `WebsocketImplProtocol` async iterable
- [#2499](https://github.com/sanic-org/sanic/pull/2499) Sanic Server WorkerManager refactor
- [#2506](https://github.com/sanic-org/sanic/pull/2506) Use `pathlib` for path resolution (for static file serving)
- [#2508](https://github.com/sanic-org/sanic/pull/2508) Use `path.parts` instead of `match` (for static file serving)
- [#2513](https://github.com/sanic-org/sanic/pull/2513) Better request cancel handling
- [#2516](https://github.com/sanic-org/sanic/pull/2516) Add request properties for HTTP method info:
- `request.is_safe`
- `request.is_idempotent`
- `request.is_cacheable`
- *See* [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) *for more information about when these apply*
- [#2522](https://github.com/sanic-org/sanic/pull/2522) Always show server location in ASGI
- [#2526](https://github.com/sanic-org/sanic/pull/2526) Cache control support for static files for returning 304 when appropriate
- [#2533](https://github.com/sanic-org/sanic/pull/2533) Refactor `_static_request_handler`
- [#2540](https://github.com/sanic-org/sanic/pull/2540) Add signals before and after handler execution
- `http.handler.before`
- `http.handler.after`
- [#2542](https://github.com/sanic-org/sanic/pull/2542) Add *[redacted]* to CLI :)
- [#2546](https://github.com/sanic-org/sanic/pull/2546) Add deprecation warning filter
- [#2550](https://github.com/sanic-org/sanic/pull/2550) Middleware priority and performance enhancements
### Bugfixes
- [#2495](https://github.com/sanic-org/sanic/pull/2495) Prevent directory traversion with static files
- [#2515](https://github.com/sanic-org/sanic/pull/2515) Do not apply double slash to paths in certain static dirs in Blueprints
### Deprecations and Removals
- [#2525](https://github.com/sanic-org/sanic/pull/2525) Warn on duplicate route names, will be prevented outright in v23.3
- [#2537](https://github.com/sanic-org/sanic/pull/2537) Raise warning and deprecation notice on duplicate exceptions, will be prevented outright in v23.3
### Developer infrastructure
- [#2504](https://github.com/sanic-org/sanic/pull/2504) Cleanup test suite
- [#2505](https://github.com/sanic-org/sanic/pull/2505) Replace Unsupported Python Version Number from the Contributing Doc
- [#2530](https://github.com/sanic-org/sanic/pull/2530) Do not include tests folder in installed package resolver
### Improved Documentation
- [#2502](https://github.com/sanic-org/sanic/pull/2502) Fix a few typos
- [#2517](https://github.com/sanic-org/sanic/pull/2517) [#2536](https://github.com/sanic-org/sanic/pull/2536) Add some type hints

View File

@@ -1 +1 @@
__version__ = "22.9.1"
__version__ = "22.12.0a0"

View File

@@ -47,7 +47,7 @@ from sanic_routing.exceptions import FinalizationError, NotFound
from sanic_routing.route import Route
from sanic.application.ext import setup_ext
from sanic.application.state import ApplicationState, Mode, ServerStage
from sanic.application.state import ApplicationState, ServerStage
from sanic.asgi import ASGIApp
from sanic.base.root import BaseSanic
from sanic.blueprint_group import BlueprintGroup
@@ -61,7 +61,7 @@ from sanic.exceptions import (
URLBuildError,
)
from sanic.handlers import ErrorHandler
from sanic.helpers import _default
from sanic.helpers import Default
from sanic.http import Stage
from sanic.log import (
LOGGING_CONFIG_DEFAULTS,
@@ -158,7 +158,6 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
)
_app_registry: Dict[str, "Sanic"] = {}
_uvloop_setting = None # TODO: Remove in v22.6
test_mode = False
def __init__(
@@ -394,8 +393,8 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
routes = [routes]
for r in routes:
r.ctx.websocket = websocket
r.ctx.static = params.get("static", False)
r.extra.websocket = websocket
r.extra.static = params.get("static", False)
r.ctx.__dict__.update(ctx)
return routes
@@ -481,17 +480,16 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
for item in blueprint:
params = {**options}
if isinstance(blueprint, BlueprintGroup):
if blueprint.url_prefix:
merge_from = [
options.get("url_prefix", ""),
blueprint.url_prefix,
]
if not isinstance(item, BlueprintGroup):
merge_from.append(item.url_prefix or "")
merged_prefix = "/".join(
u.strip("/") for u in merge_from
).rstrip("/")
params["url_prefix"] = f"/{merged_prefix}"
merge_from = [
options.get("url_prefix", ""),
blueprint.url_prefix or "",
]
if not isinstance(item, BlueprintGroup):
merge_from.append(item.url_prefix or "")
merged_prefix = "/".join(
u.strip("/") for u in merge_from if u
).rstrip("/")
params["url_prefix"] = f"/{merged_prefix}"
for _attr in ["version", "strict_slashes"]:
if getattr(item, _attr) is None:
@@ -589,7 +587,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
uri = route.path
if getattr(route.ctx, "static", None):
if getattr(route.extra, "static", None):
filename = kwargs.pop("filename", "")
# it's static folder
if "__file_uri__" in uri:
@@ -622,18 +620,18 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
host = kwargs.pop("_host", None)
external = kwargs.pop("_external", False) or bool(host)
scheme = kwargs.pop("_scheme", "")
if route.ctx.hosts and external:
if not host and len(route.ctx.hosts) > 1:
if route.extra.hosts and external:
if not host and len(route.extra.hosts) > 1:
raise ValueError(
f"Host is ambiguous: {', '.join(route.ctx.hosts)}"
f"Host is ambiguous: {', '.join(route.extra.hosts)}"
)
elif host and host not in route.ctx.hosts:
elif host and host not in route.extra.hosts:
raise ValueError(
f"Requested host ({host}) is not available for this "
f"route: {route.ctx.hosts}"
f"route: {route.extra.hosts}"
)
elif not host:
host = list(route.ctx.hosts)[0]
host = list(route.extra.hosts)[0]
if scheme and not external:
raise ValueError("When specifying _scheme, _external must be True")
@@ -709,7 +707,10 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
# -------------------------------------------------------------------- #
async def handle_exception(
self, request: Request, exception: BaseException
self,
request: Request,
exception: BaseException,
run_middleware: bool = True,
): # no cov
"""
A handler that catches specific exceptions and outputs a response.
@@ -718,6 +719,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
:param exception: The exception that was raised
:raises ServerError: response 500
"""
response = None
await self.dispatch(
"http.lifecycle.exception",
inline=True,
@@ -758,9 +760,11 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
# -------------------------------------------- #
# Request Middleware
# -------------------------------------------- #
response = await self._run_request_middleware(
request, request_name=None
)
if run_middleware:
middleware = (
request.route and request.route.extra.request_middleware
) or self.request_middleware
response = await self._run_request_middleware(request, middleware)
# No middleware results
if not response:
try:
@@ -840,7 +844,13 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
# Define `response` var here to remove warnings about
# allocation before assignment below.
response = None
response: Optional[
Union[
BaseHTTPResponse,
Coroutine[Any, Any, Optional[BaseHTTPResponse]],
]
] = None
run_middleware = True
try:
await self.dispatch(
@@ -872,7 +882,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
if (
request.stream
and request.stream.request_body
and not route.ctx.ignore_body
and not route.extra.ignore_body
):
if hasattr(handler, "is_stream"):
@@ -885,9 +895,11 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
# -------------------------------------------- #
# Request Middleware
# -------------------------------------------- #
response = await self._run_request_middleware(
request, request_name=route.name
)
run_middleware = False
if request.route.extra.request_middleware:
response = await self._run_request_middleware(
request, request.route.extra.request_middleware
)
# No middleware results
if not response:
@@ -928,7 +940,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
if request.stream is not None:
response = request.stream.response
elif response is not None:
response = await request.respond(response)
response = await request.respond(response) # type: ignore
elif not hasattr(handler, "is_websocket"):
response = request.stream.response # type: ignore
@@ -946,7 +958,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
...
await response.send(end_stream=True)
elif isinstance(response, ResponseStream):
resp = await response(request)
resp = await response(request) # type: ignore
await self.dispatch(
"http.lifecycle.response",
inline=True,
@@ -955,7 +967,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"response": resp,
},
)
await response.eof()
await response.eof() # type: ignore
else:
if not hasattr(handler, "is_websocket"):
raise ServerError(
@@ -967,7 +979,9 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
raise
except Exception as e:
# Response Generation Failed
await self.handle_exception(request, e)
await self.handle_exception(
request, e, run_middleware=run_middleware
)
async def _websocket_handler(
self, handler, request, *args, subprotocols=None, **kwargs
@@ -1036,86 +1050,72 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
# -------------------------------------------------------------------- #
async def _run_request_middleware(
self, request, request_name=None
self, request, middleware_collection
): # no cov
# The if improves speed. I don't know why
named_middleware = self.named_request_middleware.get(
request_name, deque()
)
applicable_middleware = self.request_middleware + named_middleware
request._request_middleware_started = True
# request.request_middleware_started is meant as a stop-gap solution
# until RFC 1630 is adopted
if applicable_middleware and not request.request_middleware_started:
request.request_middleware_started = True
for middleware in middleware_collection:
await self.dispatch(
"http.middleware.before",
inline=True,
context={
"request": request,
"response": None,
},
condition={"attach_to": "request"},
)
for middleware in applicable_middleware:
await self.dispatch(
"http.middleware.before",
inline=True,
context={
"request": request,
"response": None,
},
condition={"attach_to": "request"},
)
response = middleware(request)
if isawaitable(response):
response = await response
response = middleware(request)
if isawaitable(response):
response = await response
await self.dispatch(
"http.middleware.after",
inline=True,
context={
"request": request,
"response": None,
},
condition={"attach_to": "request"},
)
await self.dispatch(
"http.middleware.after",
inline=True,
context={
"request": request,
"response": None,
},
condition={"attach_to": "request"},
)
if response:
return response
if response:
return response
return None
async def _run_response_middleware(
self, request, response, request_name=None
self, request, response, middleware_collection
): # no cov
named_middleware = self.named_response_middleware.get(
request_name, deque()
)
applicable_middleware = self.response_middleware + named_middleware
if applicable_middleware:
for middleware in applicable_middleware:
await self.dispatch(
"http.middleware.before",
inline=True,
context={
"request": request,
"response": response,
},
condition={"attach_to": "response"},
)
for middleware in middleware_collection:
await self.dispatch(
"http.middleware.before",
inline=True,
context={
"request": request,
"response": response,
},
condition={"attach_to": "response"},
)
_response = middleware(request, response)
if isawaitable(_response):
_response = await _response
_response = middleware(request, response)
if isawaitable(_response):
_response = await _response
await self.dispatch(
"http.middleware.after",
inline=True,
context={
"request": request,
"response": _response if _response else response,
},
condition={"attach_to": "response"},
)
await self.dispatch(
"http.middleware.after",
inline=True,
context={
"request": request,
"response": _response if _response else response,
},
condition={"attach_to": "response"},
)
if _response:
response = _response
if isinstance(response, BaseHTTPResponse):
response = request.stream.respond(response)
break
if _response:
response = _response
if isinstance(response, BaseHTTPResponse):
response = request.stream.respond(response)
break
return response
def _build_endpoint_name(self, *parts):
@@ -1344,18 +1344,6 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
def debug(self):
return self.state.is_debug
@debug.setter
def debug(self, value: bool):
deprecation(
"Setting the value of a Sanic application's debug value directly "
"is deprecated and will be removed in v22.9. Please set it using "
"the CLI, app.run, app.prepare, or directly set "
"app.state.mode to Mode.DEBUG.",
22.9,
)
mode = Mode.DEBUG if value else Mode.PRODUCTION
self.state.mode = mode
@property
def auto_reload(self):
return self.config.AUTO_RELOAD
@@ -1372,58 +1360,6 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"""
return self._state
@property
def is_running(self):
deprecation(
"Use of the is_running property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
return self.state.is_running
@is_running.setter
def is_running(self, value: bool):
deprecation(
"Use of the is_running property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
self.state.is_running = value
@property
def is_stopping(self):
deprecation(
"Use of the is_stopping property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
return self.state.is_stopping
@is_stopping.setter
def is_stopping(self, value: bool):
deprecation(
"Use of the is_stopping property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
self.state.is_stopping = value
@property
def reload_dirs(self):
return self.state.reload_dirs
@@ -1516,7 +1452,24 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
return cls.get_app("__mp_main__", force_create=force_create)
if force_create:
return cls(name)
raise SanicException(f'Sanic app name "{name}" not found.')
raise SanicException(
f"Sanic app name '{name}' not found.\n"
"App instantiation must occur outside "
"if __name__ == '__main__' "
"block or by using an AppLoader.\nSee "
"https://sanic.dev/en/guide/deployment/app-loader.html"
" for more details."
)
@classmethod
def _check_uvloop_conflict(cls) -> None:
values = {app.config.USE_UVLOOP for app in cls._app_registry.values()}
if len(values) > 1:
error_logger.warning(
"It looks like you're running several apps with different "
"uvloop settings. This is not supported and may lead to "
"unintended behaviour."
)
# -------------------------------------------------------------------- #
# Lifecycle
@@ -1528,6 +1481,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
except FinalizationError as e:
if not Sanic.test_mode:
raise e
self.finalize_middleware()
def signalize(self, allow_fail_builtin=True):
self.signal_router.allow_fail_builtin = allow_fail_builtin
@@ -1547,7 +1501,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
if self.state.is_debug and self.config.TOUCHUP is not True:
self.config.TOUCHUP = False
elif self.config.TOUCHUP is _default:
elif isinstance(self.config.TOUCHUP, Default):
self.config.TOUCHUP = True
# Setup routers
@@ -1566,17 +1520,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
23.3,
)
# TODO: Replace in v22.6 to check against apps in app registry
if (
self.__class__._uvloop_setting is not None
and self.__class__._uvloop_setting != self.config.USE_UVLOOP
):
error_logger.warning(
"It looks like you're running several apps with different "
"uvloop settings. This is not supported and may lead to "
"unintended behaviour."
)
self.__class__._uvloop_setting = self.config.USE_UVLOOP
Sanic._check_uvloop_conflict()
# Startup time optimizations
if self.state.primary:

View File

@@ -8,11 +8,6 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sanic import Sanic
try:
from sanic_ext import Extend # type: ignore
except ImportError:
...
def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
if not app.config.AUTO_EXTEND:
@@ -33,7 +28,7 @@ def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
return
if not getattr(app, "_ext", None):
Ext: Extend = getattr(sanic_ext, "Extend")
Ext = getattr(sanic_ext, "Extend")
app._ext = Ext(app, **kwargs)
return app.ext

View File

@@ -7,7 +7,7 @@ from urllib.parse import quote
from sanic.compat import Header
from sanic.exceptions import ServerError
from sanic.helpers import _default
from sanic.helpers import Default
from sanic.http import Stage
from sanic.log import logger
from sanic.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport
@@ -61,7 +61,7 @@ class Lifespan:
await self.asgi_app.sanic_app._server_event("init", "before")
await self.asgi_app.sanic_app._server_event("init", "after")
if self.asgi_app.sanic_app.config.USE_UVLOOP is not _default:
if not isinstance(self.asgi_app.sanic_app.config.USE_UVLOOP, Default):
warnings.warn(
"You have set the USE_UVLOOP configuration option, but Sanic "
"cannot control the event loop when running in ASGI mode."
@@ -234,4 +234,7 @@ class ASGIApp:
self.stage = Stage.HANDLER
await self.sanic_app.handle_request(self.request)
except Exception as e:
await self.sanic_app.handle_exception(self.request, e)
try:
await self.sanic_app.handle_exception(self.request, e)
except Exception as exc:
await self.sanic_app.handle_exception(self.request, exc, False)

View File

@@ -406,7 +406,7 @@ class Blueprint(BaseSanic):
self.routes += [route for route in routes if isinstance(route, Route)]
self.websocket_routes += [
route for route in self.routes if route.ctx.websocket
route for route in self.routes if route.extra.websocket
]
self.middlewares += middleware
self.exceptions += exception_handlers

View File

@@ -3,6 +3,7 @@ import os
import signal
import sys
from enum import Enum
from typing import Awaitable
from multidict import CIMultiDict # type: ignore
@@ -19,6 +20,31 @@ except ImportError:
pass
# Python 3.11 changed the way Enum formatting works for mixed-in types.
if sys.version_info < (3, 11, 0):
class StrEnum(str, Enum):
pass
else:
from enum import StrEnum # type: ignore # noqa
class UpperStrEnum(StrEnum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
def enable_windows_color_support():
import ctypes

View File

@@ -8,11 +8,11 @@ from pathlib import Path
from typing import Any, Callable, Dict, Optional, Sequence, Union
from warnings import filterwarnings
from sanic.constants import LocalCertCreator
from sanic.constants import LocalCertCreator, RestartOrder
from sanic.errorpages import DEFAULT_FORMAT, check_error_format
from sanic.helpers import Default, _default
from sanic.http import Http
from sanic.log import deprecation, error_logger
from sanic.log import error_logger
from sanic.utils import load_module_from_file_location, str_to_bool
@@ -63,6 +63,7 @@ DEFAULT_CONFIG = {
"REQUEST_MAX_SIZE": 100000000, # 100 megabytes
"REQUEST_TIMEOUT": 60, # 60 seconds
"RESPONSE_TIMEOUT": 60, # 60 seconds
"RESTART_ORDER": RestartOrder.SHUTDOWN_FIRST,
"TLS_CERT_PASSWORD": "",
"TOUCHUP": _default,
"USE_UVLOOP": _default,
@@ -71,10 +72,6 @@ DEFAULT_CONFIG = {
"WEBSOCKET_PING_TIMEOUT": 20,
}
# These values will be removed from the Config object in v22.6 and moved
# to the application state
DEPRECATED_CONFIG = ("SERVER_RUNNING", "RELOADER_PROCESS", "RELOADED_FILES")
class DescriptorMeta(type):
def __init__(cls, *_):
@@ -114,6 +111,7 @@ class Config(dict, metaclass=DescriptorMeta):
REQUEST_MAX_SIZE: int
REQUEST_TIMEOUT: int
RESPONSE_TIMEOUT: int
RESTART_ORDER: Union[str, RestartOrder]
SERVER_NAME: str
TLS_CERT_PASSWORD: str
TOUCHUP: Union[Default, bool]
@@ -132,6 +130,7 @@ class Config(dict, metaclass=DescriptorMeta):
):
defaults = defaults or {}
super().__init__({**DEFAULT_CONFIG, **defaults})
self._configure_warnings()
self._converters = [str, str_to_bool, float, int]
@@ -149,7 +148,6 @@ class Config(dict, metaclass=DescriptorMeta):
self.load_environment_vars(SANIC_PREFIX)
self._configure_header_size()
self._configure_warnings()
self._check_error_format()
self._init = True
@@ -198,12 +196,16 @@ class Config(dict, metaclass=DescriptorMeta):
self.LOCAL_CERT_CREATOR = LocalCertCreator[
self.LOCAL_CERT_CREATOR.upper()
]
elif attr == "RESTART_ORDER" and not isinstance(
self.RESTART_ORDER, RestartOrder
):
self.RESTART_ORDER = RestartOrder[self.RESTART_ORDER.upper()]
elif attr == "DEPRECATION_FILTER":
self._configure_warnings()
@property
def FALLBACK_ERROR_FORMAT(self) -> str:
if self._FALLBACK_ERROR_FORMAT is _default:
if isinstance(self._FALLBACK_ERROR_FORMAT, Default):
return DEFAULT_FORMAT
return self._FALLBACK_ERROR_FORMAT
@@ -211,7 +213,7 @@ class Config(dict, metaclass=DescriptorMeta):
def FALLBACK_ERROR_FORMAT(self, value):
self._check_error_format(value)
if (
self._FALLBACK_ERROR_FORMAT is not _default
not isinstance(self._FALLBACK_ERROR_FORMAT, Default)
and value != self._FALLBACK_ERROR_FORMAT
):
error_logger.warning(
@@ -241,7 +243,9 @@ class Config(dict, metaclass=DescriptorMeta):
"""
Looks for prefixed environment variables and applies them to the
configuration if present. This is called automatically when Sanic
starts up to load environment variables into config.
starts up to load environment variables into config. Environment
variables should start with the defined prefix and should only
contain uppercase letters.
It will automatically hydrate the following types:
@@ -267,12 +271,9 @@ class Config(dict, metaclass=DescriptorMeta):
`See user guide re: config
<https://sanicframework.org/guide/deployment/configuration.html>`__
"""
lower_case_var_found = False
for key, value in environ.items():
if not key.startswith(prefix):
if not key.startswith(prefix) or not key.isupper():
continue
if not key.isupper():
lower_case_var_found = True
_, config_key = key.split(prefix, 1)
@@ -282,12 +283,6 @@ class Config(dict, metaclass=DescriptorMeta):
break
except ValueError:
pass
if lower_case_var_found:
deprecation(
"Lowercase environment variables will not be "
"loaded into Sanic config beginning in v22.9.",
22.9,
)
def update_config(self, config: Union[bytes, str, dict, Any]):
"""

View File

@@ -1,19 +1,9 @@
from enum import Enum, auto
from enum import auto
from sanic.compat import UpperStrEnum
class HTTPMethod(str, Enum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
class HTTPMethod(UpperStrEnum):
GET = auto()
POST = auto()
@@ -24,15 +14,19 @@ class HTTPMethod(str, Enum):
DELETE = auto()
class LocalCertCreator(str, Enum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
class LocalCertCreator(UpperStrEnum):
AUTO = auto()
TRUSTME = auto()
MKCERT = auto()
class RestartOrder(UpperStrEnum):
SHUTDOWN_FIRST = auto()
STARTUP_FIRST = auto()
HTTP_METHODS = tuple(HTTPMethod.__members__.values())
SAFE_HTTP_METHODS = (HTTPMethod.GET, HTTPMethod.HEAD, HTTPMethod.OPTIONS)
IDEMPOTENT_HTTP_METHODS = (

View File

@@ -448,8 +448,8 @@ def exception_response(
# from the route
if request.route:
try:
if request.route.ctx.error_format:
render_format = request.route.ctx.error_format
if request.route.extra.error_format:
render_format = request.route.extra.error_format
except AttributeError:
...

View File

@@ -8,6 +8,10 @@ class RequestCancelled(CancelledError):
quiet = True
class ServerKilled(Exception):
...
class SanicException(Exception):
message: str = ""

View File

@@ -16,6 +16,7 @@ from sanic.exceptions import (
PayloadTooLarge,
RequestCancelled,
ServerError,
ServiceUnavailable,
)
from sanic.headers import format_http1_response
from sanic.helpers import has_message_body
@@ -428,7 +429,13 @@ class Http(Stream, metaclass=TouchUpMeta):
if self.request is None:
self.create_empty_request()
await app.handle_exception(self.request, exception)
request_middleware = not isinstance(exception, ServiceUnavailable)
try:
await app.handle_exception(
self.request, exception, request_middleware
)
except Exception as e:
await app.handle_exception(self.request, e, False)
def create_empty_request(self) -> None:
"""

View File

@@ -72,7 +72,8 @@ def get_ssl_context(
"without passing a TLS certificate. If you are developing "
"locally, please enable DEVELOPMENT mode and Sanic will "
"generate a localhost TLS certificate. For more information "
"please see: ___."
"please see: https://sanic.dev/en/guide/deployment/development."
"html#automatic-tls-certificate."
)
creator = CertCreator.select(
@@ -151,7 +152,8 @@ class CertCreator(ABC):
raise SanicException(
"Sanic could not find package to create a TLS certificate. "
"You must have either mkcert or trustme installed. See "
"_____ for more details."
"https://sanic.dev/en/guide/deployment/development.html"
"#automatic-tls-certificate for more details."
)
return creator
@@ -203,7 +205,8 @@ class MkcertCreator(CertCreator):
"to proceed. Installation instructions can be found here: "
"https://github.com/FiloSottile/mkcert.\n"
"Find out more information about your options here: "
"_____"
"https://sanic.dev/en/guide/deployment/development.html#"
"automatic-tls-certificate"
) from e
def generate_cert(self, localhost: str) -> ssl.SSLContext:
@@ -260,7 +263,8 @@ class TrustmeCreator(CertCreator):
"to proceed. Installation instructions can be found here: "
"https://github.com/python-trio/trustme.\n"
"Find out more information about your options here: "
"_____"
"https://sanic.dev/en/guide/deployment/development.html#"
"automatic-tls-certificate"
)
def generate_cert(self, localhost: str) -> ssl.SSLContext:

View File

@@ -1,11 +1,10 @@
import logging
import sys
from enum import Enum
from typing import Any, Dict
from warnings import warn
from sanic.compat import is_atty
from sanic.compat import StrEnum, is_atty
LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
@@ -25,6 +24,12 @@ LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
"propagate": True,
"qualname": "sanic.access",
},
"sanic.server": {
"level": "INFO",
"handlers": ["console"],
"propagate": True,
"qualname": "sanic.server",
},
},
handlers={
"console": {
@@ -62,7 +67,7 @@ Defult logging configuration
"""
class Colors(str, Enum): # no cov
class Colors(StrEnum): # no cov
END = "\033[0m"
BOLD = "\033[1m"
BLUE = "\033[34m"
@@ -101,6 +106,12 @@ Logger used by Sanic for access logging
"""
access_logger.addFilter(_verbosity_filter)
server_logger = logging.getLogger("sanic.server") # no cov
"""
Logger used by Sanic for server related messages
"""
logger.addFilter(_verbosity_filter)
def deprecation(message: str, version: float): # no cov
version_info = f"[DEPRECATION v{version}] "

66
sanic/middleware.py Normal file
View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from collections import deque
from enum import IntEnum, auto
from itertools import count
from typing import Deque, Sequence, Union
from sanic.models.handler_types import MiddlewareType
class MiddlewareLocation(IntEnum):
REQUEST = auto()
RESPONSE = auto()
class Middleware:
_counter = count()
__slots__ = ("func", "priority", "location", "definition")
def __init__(
self,
func: MiddlewareType,
location: MiddlewareLocation,
priority: int = 0,
) -> None:
self.func = func
self.priority = priority
self.location = location
self.definition = next(Middleware._counter)
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
def __repr__(self) -> str:
return (
f"{self.__class__.__name__}("
f"func=<function {self.func.__name__}>, "
f"priority={self.priority}, "
f"location={self.location.name})"
)
@property
def order(self):
return (self.priority, -self.definition)
@classmethod
def convert(
cls,
*middleware_collections: Sequence[Union[Middleware, MiddlewareType]],
location: MiddlewareLocation,
) -> Deque[Middleware]:
return deque(
[
middleware
if isinstance(middleware, Middleware)
else Middleware(middleware, location)
for collection in middleware_collections
for middleware in collection
]
)
@classmethod
def reset_count(cls):
cls._counter = count()
cls.count = next(cls._counter)

View File

@@ -1,11 +1,17 @@
from collections import deque
from functools import partial
from operator import attrgetter
from typing import List
from sanic.base.meta import SanicMeta
from sanic.middleware import Middleware, MiddlewareLocation
from sanic.models.futures import FutureMiddleware
from sanic.router import Router
class MiddlewareMixin(metaclass=SanicMeta):
router: Router
def __init__(self, *args, **kwargs) -> None:
self._future_middleware: List[FutureMiddleware] = []
@@ -13,7 +19,12 @@ class MiddlewareMixin(metaclass=SanicMeta):
raise NotImplementedError # noqa
def middleware(
self, middleware_or_request, attach_to="request", apply=True
self,
middleware_or_request,
attach_to="request",
apply=True,
*,
priority=0
):
"""
Decorate and register middleware to be called before a request
@@ -30,6 +41,12 @@ class MiddlewareMixin(metaclass=SanicMeta):
def register_middleware(middleware, attach_to="request"):
nonlocal apply
location = (
MiddlewareLocation.REQUEST
if attach_to == "request"
else MiddlewareLocation.RESPONSE
)
middleware = Middleware(middleware, location, priority=priority)
future_middleware = FutureMiddleware(middleware, attach_to)
self._future_middleware.append(future_middleware)
if apply:
@@ -46,7 +63,7 @@ class MiddlewareMixin(metaclass=SanicMeta):
register_middleware, attach_to=middleware_or_request
)
def on_request(self, middleware=None):
def on_request(self, middleware=None, *, priority=0):
"""Register a middleware to be called before a request is handled.
This is the same as *@app.middleware('request')*.
@@ -54,11 +71,13 @@ class MiddlewareMixin(metaclass=SanicMeta):
:param: middleware: A callable that takes in request.
"""
if callable(middleware):
return self.middleware(middleware, "request")
return self.middleware(middleware, "request", priority=priority)
else:
return partial(self.middleware, attach_to="request")
return partial(
self.middleware, attach_to="request", priority=priority
)
def on_response(self, middleware=None):
def on_response(self, middleware=None, *, priority=0):
"""Register a middleware to be called after a response is created.
This is the same as *@app.middleware('response')*.
@@ -67,6 +86,57 @@ class MiddlewareMixin(metaclass=SanicMeta):
A callable that takes in a request and its response.
"""
if callable(middleware):
return self.middleware(middleware, "response")
return self.middleware(middleware, "response", priority=priority)
else:
return partial(self.middleware, attach_to="response")
return partial(
self.middleware, attach_to="response", priority=priority
)
def finalize_middleware(self):
for route in self.router.routes:
request_middleware = Middleware.convert(
self.request_middleware,
self.named_request_middleware.get(route.name, deque()),
location=MiddlewareLocation.REQUEST,
)
response_middleware = Middleware.convert(
self.response_middleware,
self.named_response_middleware.get(route.name, deque()),
location=MiddlewareLocation.RESPONSE,
)
route.extra.request_middleware = deque(
sorted(
request_middleware,
key=attrgetter("order"),
reverse=True,
)
)
route.extra.response_middleware = deque(
sorted(
response_middleware,
key=attrgetter("order"),
reverse=True,
)[::-1]
)
request_middleware = Middleware.convert(
self.request_middleware,
location=MiddlewareLocation.REQUEST,
)
response_middleware = Middleware.convert(
self.response_middleware,
location=MiddlewareLocation.RESPONSE,
)
self.request_middleware = deque(
sorted(
request_middleware,
key=attrgetter("order"),
reverse=True,
)
)
self.response_middleware = deque(
sorted(
response_middleware,
key=attrgetter("order"),
reverse=True,
)[::-1]
)

View File

@@ -1,15 +1,16 @@
from ast import NodeVisitor, Return, parse
from contextlib import suppress
from email.utils import formatdate
from functools import partial, wraps
from inspect import getsource, signature
from mimetypes import guess_type
from os import path
from pathlib import Path, PurePath
from textwrap import dedent
from time import gmtime, strftime
from typing import (
Any,
Callable,
Dict,
Iterable,
List,
Optional,
@@ -31,21 +32,13 @@ from sanic.handlers import ContentRangeHandler
from sanic.log import error_logger
from sanic.models.futures import FutureRoute, FutureStatic
from sanic.models.handler_types import RouteHandler
from sanic.response import HTTPResponse, file, file_stream
from sanic.response import HTTPResponse, file, file_stream, validate_file
from sanic.types import HashableDict
RouteWrapper = Callable[
[RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]]
]
RESTRICTED_ROUTE_CONTEXT = (
"ignore_body",
"stream",
"hosts",
"static",
"error_format",
"websocket",
)
class RouteMixin(metaclass=SanicMeta):
@@ -790,24 +783,9 @@ class RouteMixin(metaclass=SanicMeta):
return name
async def _static_request_handler(
self,
file_or_directory,
use_modified_since,
use_content_range,
stream_large_files,
request,
content_type=None,
__file_uri__=None,
):
# Merge served directory and requested file if provided
async def _get_file_path(self, file_or_directory, __file_uri__, not_found):
file_path_raw = Path(unquote(file_or_directory))
root_path = file_path = file_path_raw.resolve()
not_found = FileNotFound(
"File not found",
path=file_or_directory,
relative_url=__file_uri__,
)
if __file_uri__:
# Strip all / that in the beginning of the URL to help prevent
@@ -834,6 +812,29 @@ class RouteMixin(metaclass=SanicMeta):
f"relative_url={__file_uri__}"
)
raise not_found
return file_path
async def _static_request_handler(
self,
file_or_directory,
use_modified_since,
use_content_range,
stream_large_files,
request,
content_type=None,
__file_uri__=None,
):
not_found = FileNotFound(
"File not found",
path=file_or_directory,
relative_url=__file_uri__,
)
# Merge served directory and requested file if provided
file_path = await self._get_file_path(
file_or_directory, __file_uri__, not_found
)
try:
headers = {}
# Check if the client has been sent this file before
@@ -841,15 +842,13 @@ class RouteMixin(metaclass=SanicMeta):
stats = None
if use_modified_since:
stats = await stat_async(file_path)
modified_since = strftime(
"%a, %d %b %Y %H:%M:%S GMT", gmtime(stats.st_mtime)
modified_since = stats.st_mtime
response = await validate_file(request.headers, modified_since)
if response:
return response
headers["Last-Modified"] = formatdate(
modified_since, usegmt=True
)
if (
request.headers.getone("if-modified-since", None)
== modified_since
):
return HTTPResponse(status=304)
headers["Last-Modified"] = modified_since
_range = None
if use_content_range:
_range = None
@@ -864,8 +863,7 @@ class RouteMixin(metaclass=SanicMeta):
pass
else:
del headers["Content-Length"]
for key, value in _range.headers.items():
headers[key] = value
headers.update(_range.headers)
if "content-type" not in headers:
content_type = (
@@ -1041,24 +1039,12 @@ class RouteMixin(metaclass=SanicMeta):
return types
def _build_route_context(self, raw):
def _build_route_context(self, raw: Dict[str, Any]) -> HashableDict:
ctx_kwargs = {
key.replace("ctx_", ""): raw.pop(key)
for key in {**raw}.keys()
if key.startswith("ctx_")
}
restricted = [
key for key in ctx_kwargs.keys() if key in RESTRICTED_ROUTE_CONTEXT
]
if restricted:
restricted_arguments = ", ".join(restricted)
raise AttributeError(
"Cannot use restricted route context: "
f"{restricted_arguments}. This limitation is only in place "
"until v22.9 when the restricted names will no longer be in"
"conflict. See https://github.com/sanic-org/sanic/issues/2303 "
"for more information."
)
if raw:
unexpected_arguments = ", ".join(raw.keys())
raise TypeError(

View File

@@ -19,7 +19,7 @@ from importlib import import_module
from multiprocessing import Manager, Pipe, get_context
from multiprocessing.context import BaseContext
from pathlib import Path
from socket import socket
from socket import SHUT_RDWR, socket
from ssl import SSLContext
from typing import (
TYPE_CHECKING,
@@ -35,12 +35,15 @@ from typing import (
cast,
)
from sanic.application.ext import setup_ext
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, is_atty
from sanic.helpers import _default
from sanic.constants import RestartOrder
from sanic.exceptions import ServerKilled
from sanic.helpers import Default
from sanic.http.constants import HTTP
from sanic.http.tls import get_ssl_context, process_to_context
from sanic.http.tls.context import SanicSSLContext
@@ -90,7 +93,8 @@ class StartupMixin(metaclass=SanicMeta):
def setup_loop(self):
if not self.asgi:
if self.config.USE_UVLOOP is True or (
self.config.USE_UVLOOP is _default and not OS_IS_WINDOWS
isinstance(self.config.USE_UVLOOP, Default)
and not OS_IS_WINDOWS
):
try_use_uvloop()
elif OS_IS_WINDOWS:
@@ -430,7 +434,7 @@ class StartupMixin(metaclass=SanicMeta):
run_async=return_asyncio_server,
)
if self.config.USE_UVLOOP is not _default:
if not isinstance(self.config.USE_UVLOOP, Default):
error_logger.warning(
"You are trying to change the uvloop configuration, but "
"this is only effective when using the run(...) method. "
@@ -558,7 +562,6 @@ class StartupMixin(metaclass=SanicMeta):
def motd(
self,
serve_location: str = "",
server_settings: Optional[Dict[str, Any]] = None,
):
if (
@@ -568,14 +571,7 @@ class StartupMixin(metaclass=SanicMeta):
or os.environ.get("SANIC_SERVER_RUNNING")
):
return
if serve_location:
deprecation(
"Specifying a serve_location in the MOTD is deprecated and "
"will be removed.",
22.9,
)
else:
serve_location = self.get_server_location(server_settings)
serve_location = self.get_server_location(server_settings)
if self.config.MOTD:
logo = get_logo(coffee=self.state.coffee)
display, extra = self.get_motd_data(server_settings)
@@ -740,15 +736,18 @@ class StartupMixin(metaclass=SanicMeta):
except IndexError:
raise RuntimeError(
f"No server information found for {primary.name}. Perhaps you "
"need to run app.prepare(...)?\n"
"See ____ for more information."
"need to run app.prepare(...)?"
) from None
socks = []
sync_manager = Manager()
setup_ext(primary)
exit_code = 0
try:
main_start = primary_server_info.settings.pop("main_start", None)
main_stop = primary_server_info.settings.pop("main_stop", None)
primary_server_info.settings.pop("main_start", None)
primary_server_info.settings.pop("main_stop", None)
main_start = primary.listeners.get("main_process_start")
main_stop = primary.listeners.get("main_process_stop")
app = primary_server_info.settings.pop("app")
app.setup_loop()
loop = new_event_loop()
@@ -816,6 +815,7 @@ class StartupMixin(metaclass=SanicMeta):
cls._get_context(),
(monitor_pub, monitor_sub),
worker_state,
cast(RestartOrder, primary.config.RESTART_ORDER),
)
if cls.should_auto_reload():
reload_dirs: Set[Path] = primary.state.reload_dirs.union(
@@ -853,6 +853,8 @@ class StartupMixin(metaclass=SanicMeta):
trigger_events(ready, loop, primary)
manager.run()
except ServerKilled:
exit_code = 1
except BaseException:
kwargs = primary_server_info.settings
error_logger.exception(
@@ -868,6 +870,7 @@ class StartupMixin(metaclass=SanicMeta):
sync_manager.shutdown()
for sock in socks:
sock.shutdown(SHUT_RDWR)
sock.close()
socks = []
trigger_events(main_stop, loop, primary)
@@ -877,6 +880,8 @@ class StartupMixin(metaclass=SanicMeta):
unix = kwargs.get("unix")
if unix:
remove_unix_socket(unix)
if exit_code:
os._exit(exit_code)
@classmethod
def serve_single(cls, primary: Optional[Sanic] = None) -> None:

View File

@@ -56,7 +56,7 @@ from sanic.headers import (
parse_xforwarded,
)
from sanic.http import Stage
from sanic.log import error_logger, logger
from sanic.log import deprecation, error_logger, logger
from sanic.models.protocol_types import TransportProtocol
from sanic.response import BaseHTTPResponse, HTTPResponse
@@ -103,6 +103,8 @@ class Request:
"_port",
"_protocol",
"_remote_addr",
"_request_middleware_started",
"_response_middleware_started",
"_scheme",
"_socket",
"_stream_id",
@@ -126,7 +128,6 @@ class Request:
"parsed_token",
"raw_url",
"responded",
"request_middleware_started",
"route",
"stream",
"transport",
@@ -178,7 +179,8 @@ class Request:
self.parsed_not_grouped_args: DefaultDict[
Tuple[bool, bool, str, str], List[Tuple[str, str]]
] = defaultdict(list)
self.request_middleware_started = False
self._request_middleware_started = False
self._response_middleware_started = False
self.responded: bool = False
self.route: Optional[Route] = None
self.stream: Optional[Stream] = None
@@ -219,6 +221,16 @@ class Request:
def generate_id(*_):
return uuid.uuid4()
@property
def request_middleware_started(self):
deprecation(
"Request.request_middleware_started has been deprecated and will"
"be removed. You should set a flag on the request context using"
"either middleware or signals if you need this feature.",
23.3,
)
return self._request_middleware_started
@property
def stream_id(self):
"""
@@ -324,9 +336,14 @@ class Request:
response = await response # type: ignore
# Run response middleware
try:
response = await self.app._run_response_middleware(
self, response, request_name=self.name
)
middleware = (
self.route and self.route.extra.response_middleware
) or self.app.response_middleware
if middleware and not self._response_middleware_started:
self._response_middleware_started = True
response = await self.app._run_response_middleware(
self, response, middleware
)
except CancelledErrors:
raise
except Exception:

View File

@@ -0,0 +1,36 @@
from .convenience import (
empty,
file,
file_stream,
html,
json,
raw,
redirect,
text,
validate_file,
)
from .types import (
BaseHTTPResponse,
HTTPResponse,
JSONResponse,
ResponseStream,
json_dumps,
)
__all__ = (
"BaseHTTPResponse",
"HTTPResponse",
"JSONResponse",
"ResponseStream",
"empty",
"json",
"text",
"raw",
"html",
"validate_file",
"file",
"redirect",
"file_stream",
"json_dumps",
)

View File

@@ -2,212 +2,20 @@ from __future__ import annotations
from datetime import datetime, timezone
from email.utils import formatdate, parsedate_to_datetime
from functools import partial
from mimetypes import guess_type
from os import path
from pathlib import PurePath
from time import time
from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
Coroutine,
Dict,
Iterator,
Optional,
Tuple,
TypeVar,
Union,
)
from typing import Any, AnyStr, Callable, Dict, Optional, Union
from urllib.parse import quote_plus
from sanic.compat import Header, open_async, stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.cookies import CookieJar
from sanic.exceptions import SanicException, ServerError
from sanic.helpers import (
Default,
_default,
has_message_body,
remove_entity_headers,
)
from sanic.http import Http
from sanic.helpers import Default, _default
from sanic.log import logger
from sanic.models.protocol_types import HTMLProtocol, Range
if TYPE_CHECKING:
from sanic.asgi import ASGIApp
from sanic.http.http3 import HTTPReceiver
from sanic.request import Request
else:
Request = TypeVar("Request")
try:
from ujson import dumps as json_dumps
except ImportError:
# This is done in order to ensure that the JSON response is
# kept consistent across both ujson and inbuilt json usage.
from json import dumps
json_dumps = partial(dumps, separators=(",", ":"))
class BaseHTTPResponse:
"""
The base class for all HTTP Responses
"""
__slots__ = (
"asgi",
"body",
"content_type",
"stream",
"status",
"headers",
"_cookies",
)
_dumps = json_dumps
def __init__(self):
self.asgi: bool = False
self.body: Optional[bytes] = None
self.content_type: Optional[str] = None
self.stream: Optional[Union[Http, ASGIApp, HTTPReceiver]] = None
self.status: int = None
self.headers = Header({})
self._cookies: Optional[CookieJar] = None
def __repr__(self):
class_name = self.__class__.__name__
return f"<{class_name}: {self.status} {self.content_type}>"
def _encode_body(self, data: Optional[AnyStr]):
if data is None:
return b""
return (
data.encode() if hasattr(data, "encode") else data # type: ignore
)
@property
def cookies(self) -> CookieJar:
"""
The response cookies. Cookies should be set and written as follows:
.. code-block:: python
response.cookies["test"] = "It worked!"
response.cookies["test"]["domain"] = ".yummy-yummy-cookie.com"
response.cookies["test"]["httponly"] = True
`See user guide re: cookies
<https://sanicframework.org/guide/basics/cookies.html>`__
:return: the cookie jar
:rtype: CookieJar
"""
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self) -> Iterator[Tuple[bytes, bytes]]:
"""
Obtain a list of header tuples encoded in bytes for sending.
Add and remove headers based on status and content_type.
:return: response headers
:rtype: Tuple[Tuple[bytes, bytes], ...]
"""
# TODO: Make a blacklist set of header names and then filter with that
if self.status in (304, 412): # Not Modified, Precondition Failed
self.headers = remove_entity_headers(self.headers)
if has_message_body(self.status):
self.headers.setdefault("content-type", self.content_type)
# Encode headers into bytes
return (
(name.encode("ascii"), f"{value}".encode(errors="surrogateescape"))
for name, value in self.headers.items()
)
async def send(
self,
data: Optional[AnyStr] = None,
end_stream: Optional[bool] = None,
) -> None:
"""
Send any pending response headers and the given data as body.
:param data: str or bytes to be written
:param end_stream: whether to close the stream after this block
"""
if data is None and end_stream is None:
end_stream = True
if self.stream is None:
raise SanicException(
"No stream is connected to the response object instance."
)
if self.stream.send is None:
if end_stream and not data:
return
raise ServerError(
"Response stream was ended, no more response data is "
"allowed to be sent."
)
data = (
data.encode() # type: ignore
if hasattr(data, "encode")
else data or b""
)
await self.stream.send(
data, # type: ignore
end_stream=end_stream or False,
)
class HTTPResponse(BaseHTTPResponse):
"""
HTTP response to be sent back to the client.
:param body: the body content to be returned
:type body: Optional[bytes]
:param status: HTTP response number. **Default=200**
:type status: int
:param headers: headers to be returned
:type headers: Optional;
:param content_type: content type to be returned (as a header)
:type content_type: Optional[str]
"""
__slots__ = ()
def __init__(
self,
body: Optional[AnyStr] = None,
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
super().__init__()
self.content_type: Optional[str] = content_type
self.body = self._encode_body(body)
self.status = status
self.headers = Header(headers or {})
self._cookies = None
async def eof(self):
await self.send("", True)
async def __aenter__(self):
return self.send
async def __aexit__(self, *_):
await self.eof()
from .types import HTTPResponse, JSONResponse, ResponseStream
def empty(
@@ -229,7 +37,7 @@ def json(
content_type: str = "application/json",
dumps: Optional[Callable[..., str]] = None,
**kwargs: Any,
) -> HTTPResponse:
) -> JSONResponse:
"""
Returns response object with body in json format.
@@ -238,13 +46,14 @@ def json(
:param headers: Custom Headers.
:param kwargs: Remaining arguments that are passed to the json encoder.
"""
if not dumps:
dumps = BaseHTTPResponse._dumps
return HTTPResponse(
dumps(body, **kwargs),
headers=headers,
return JSONResponse(
body,
status=status,
headers=headers,
content_type=content_type,
dumps=dumps,
**kwargs,
)
@@ -465,80 +274,6 @@ def redirect(
)
class ResponseStream:
"""
ResponseStream is a compat layer to bridge the gap after the deprecation
of StreamingHTTPResponse. It will be removed when:
- file_stream is moved to new style streaming
- file and file_stream are combined into a single API
"""
__slots__ = (
"_cookies",
"content_type",
"headers",
"request",
"response",
"status",
"streaming_fn",
)
def __init__(
self,
streaming_fn: Callable[
[Union[BaseHTTPResponse, ResponseStream]],
Coroutine[Any, Any, None],
],
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
self.streaming_fn = streaming_fn
self.status = status
self.headers = headers or Header()
self.content_type = content_type
self.request: Optional[Request] = None
self._cookies: Optional[CookieJar] = None
async def write(self, message: str):
await self.response.send(message)
async def stream(self) -> HTTPResponse:
if not self.request:
raise ServerError("Attempted response to unknown request")
self.response = await self.request.respond(
headers=self.headers,
status=self.status,
content_type=self.content_type,
)
await self.streaming_fn(self)
return self.response
async def eof(self) -> None:
await self.response.eof()
@property
def cookies(self) -> CookieJar:
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self):
return self.response.processed_headers
@property
def body(self):
return self.response.body
def __call__(self, request: Request) -> ResponseStream:
self.request = request
return self
def __await__(self):
return self.stream().__await__()
async def file_stream(
location: Union[str, PurePath],
status: int = 200,

453
sanic/response/types.py Normal file
View File

@@ -0,0 +1,453 @@
from __future__ import annotations
from functools import partial
from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
Coroutine,
Dict,
Iterator,
Optional,
Tuple,
TypeVar,
Union,
)
from sanic.compat import Header
from sanic.cookies import CookieJar
from sanic.exceptions import SanicException, ServerError
from sanic.helpers import (
Default,
_default,
has_message_body,
remove_entity_headers,
)
from sanic.http import Http
if TYPE_CHECKING:
from sanic.asgi import ASGIApp
from sanic.http.http3 import HTTPReceiver
from sanic.request import Request
else:
Request = TypeVar("Request")
try:
from ujson import dumps as json_dumps
except ImportError:
# This is done in order to ensure that the JSON response is
# kept consistent across both ujson and inbuilt json usage.
from json import dumps
json_dumps = partial(dumps, separators=(",", ":"))
class BaseHTTPResponse:
"""
The base class for all HTTP Responses
"""
__slots__ = (
"asgi",
"body",
"content_type",
"stream",
"status",
"headers",
"_cookies",
)
_dumps = json_dumps
def __init__(self):
self.asgi: bool = False
self.body: Optional[bytes] = None
self.content_type: Optional[str] = None
self.stream: Optional[Union[Http, ASGIApp, HTTPReceiver]] = None
self.status: int = None
self.headers = Header({})
self._cookies: Optional[CookieJar] = None
def __repr__(self):
class_name = self.__class__.__name__
return f"<{class_name}: {self.status} {self.content_type}>"
def _encode_body(self, data: Optional[AnyStr]):
if data is None:
return b""
return (
data.encode() if hasattr(data, "encode") else data # type: ignore
)
@property
def cookies(self) -> CookieJar:
"""
The response cookies. Cookies should be set and written as follows:
.. code-block:: python
response.cookies["test"] = "It worked!"
response.cookies["test"]["domain"] = ".yummy-yummy-cookie.com"
response.cookies["test"]["httponly"] = True
`See user guide re: cookies
<https://sanic.dev/en/guide/basics/cookies.html>`
:return: the cookie jar
:rtype: CookieJar
"""
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self) -> Iterator[Tuple[bytes, bytes]]:
"""
Obtain a list of header tuples encoded in bytes for sending.
Add and remove headers based on status and content_type.
:return: response headers
:rtype: Tuple[Tuple[bytes, bytes], ...]
"""
# TODO: Make a blacklist set of header names and then filter with that
if self.status in (304, 412): # Not Modified, Precondition Failed
self.headers = remove_entity_headers(self.headers)
if has_message_body(self.status):
self.headers.setdefault("content-type", self.content_type)
# Encode headers into bytes
return (
(name.encode("ascii"), f"{value}".encode(errors="surrogateescape"))
for name, value in self.headers.items()
)
async def send(
self,
data: Optional[AnyStr] = None,
end_stream: Optional[bool] = None,
) -> None:
"""
Send any pending response headers and the given data as body.
:param data: str or bytes to be written
:param end_stream: whether to close the stream after this block
"""
if data is None and end_stream is None:
end_stream = True
if self.stream is None:
raise SanicException(
"No stream is connected to the response object instance."
)
if self.stream.send is None:
if end_stream and not data:
return
raise ServerError(
"Response stream was ended, no more response data is "
"allowed to be sent."
)
data = (
data.encode() # type: ignore
if hasattr(data, "encode")
else data or b""
)
await self.stream.send(
data, # type: ignore
end_stream=end_stream or False,
)
class HTTPResponse(BaseHTTPResponse):
"""
HTTP response to be sent back to the client.
:param body: the body content to be returned
:type body: Optional[bytes]
:param status: HTTP response number. **Default=200**
:type status: int
:param headers: headers to be returned
:type headers: Optional;
:param content_type: content type to be returned (as a header)
:type content_type: Optional[str]
"""
__slots__ = ()
def __init__(
self,
body: Optional[Any] = None,
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
super().__init__()
self.content_type: Optional[str] = content_type
self.body = self._encode_body(body)
self.status = status
self.headers = Header(headers or {})
self._cookies = None
async def eof(self):
await self.send("", True)
async def __aenter__(self):
return self.send
async def __aexit__(self, *_):
await self.eof()
class JSONResponse(HTTPResponse):
"""
HTTP response to be sent back to the client, when the response
is of json type. Offers several utilities to manipulate common
json data types.
:param body: the body content to be returned
:type body: Optional[Any]
:param status: HTTP response number. **Default=200**
:type status: int
:param headers: headers to be returned
:type headers: Optional
:param content_type: content type to be returned (as a header)
:type content_type: Optional[str]
:param dumps: json.dumps function to use
:type dumps: Optional[Callable]
"""
__slots__ = (
"_body",
"_body_manually_set",
"_initialized",
"_raw_body",
"_use_dumps",
"_use_dumps_kwargs",
)
def __init__(
self,
body: Optional[Any] = None,
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
dumps: Optional[Callable[..., str]] = None,
**kwargs: Any,
):
self._initialized = False
self._body_manually_set = False
self._use_dumps = dumps or BaseHTTPResponse._dumps
self._use_dumps_kwargs = kwargs
self._raw_body = body
super().__init__(
self._encode_body(self._use_dumps(body, **self._use_dumps_kwargs)),
headers=headers,
status=status,
content_type=content_type,
)
self._initialized = True
def _check_body_not_manually_set(self):
if self._body_manually_set:
raise SanicException(
"Cannot use raw_body after body has been manually set."
)
@property
def raw_body(self) -> Optional[Any]:
"""Returns the raw body, as long as body has not been manually
set previously.
NOTE: This object should not be mutated, as it will not be
reflected in the response body. If you need to mutate the
response body, consider using one of the provided methods in
this class or alternatively call set_body() with the mutated
object afterwards or set the raw_body property to it.
"""
self._check_body_not_manually_set()
return self._raw_body
@raw_body.setter
def raw_body(self, value: Any):
self._body_manually_set = False
self._body = self._encode_body(
self._use_dumps(value, **self._use_dumps_kwargs)
)
self._raw_body = value
@property # type: ignore
def body(self) -> Optional[bytes]: # type: ignore
return self._body
@body.setter
def body(self, value: Optional[bytes]):
self._body = value
if not self._initialized:
return
self._body_manually_set = True
def set_body(
self,
body: Any,
dumps: Optional[Callable[..., str]] = None,
**dumps_kwargs: Any,
) -> None:
"""Sets a new response body using the given dumps function
and kwargs, or falling back to the defaults given when
creating the object if none are specified.
"""
self._body_manually_set = False
self._raw_body = body
use_dumps = dumps or self._use_dumps
use_dumps_kwargs = dumps_kwargs if dumps else self._use_dumps_kwargs
self._body = self._encode_body(use_dumps(body, **use_dumps_kwargs))
def append(self, value: Any) -> None:
"""Appends a value to the response raw_body, ensuring that
body is kept up to date. This can only be used if raw_body
is a list.
"""
self._check_body_not_manually_set()
if not isinstance(self._raw_body, list):
raise SanicException("Cannot append to a non-list object.")
self._raw_body.append(value)
self.raw_body = self._raw_body
def extend(self, value: Any) -> None:
"""Extends the response's raw_body with the given values, ensuring
that body is kept up to date. This can only be used if raw_body is
a list.
"""
self._check_body_not_manually_set()
if not isinstance(self._raw_body, list):
raise SanicException("Cannot extend a non-list object.")
self._raw_body.extend(value)
self.raw_body = self._raw_body
def update(self, *args, **kwargs) -> None:
"""Updates the response's raw_body with the given values, ensuring
that body is kept up to date. This can only be used if raw_body is
a dict.
"""
self._check_body_not_manually_set()
if not isinstance(self._raw_body, dict):
raise SanicException("Cannot update a non-dict object.")
self._raw_body.update(*args, **kwargs)
self.raw_body = self._raw_body
def pop(self, key: Any, default: Any = _default) -> Any:
"""Pops a key from the response's raw_body, ensuring that body is
kept up to date. This can only be used if raw_body is a dict or a
list.
"""
self._check_body_not_manually_set()
if not isinstance(self._raw_body, (list, dict)):
raise SanicException(
"Cannot pop from a non-list and non-dict object."
)
if isinstance(default, Default):
value = self._raw_body.pop(key)
elif isinstance(self._raw_body, list):
raise TypeError("pop doesn't accept a default argument for lists")
else:
value = self._raw_body.pop(key, default)
self.raw_body = self._raw_body
return value
class ResponseStream:
"""
ResponseStream is a compat layer to bridge the gap after the deprecation
of StreamingHTTPResponse. It will be removed when:
- file_stream is moved to new style streaming
- file and file_stream are combined into a single API
"""
__slots__ = (
"_cookies",
"content_type",
"headers",
"request",
"response",
"status",
"streaming_fn",
)
def __init__(
self,
streaming_fn: Callable[
[Union[BaseHTTPResponse, ResponseStream]],
Coroutine[Any, Any, None],
],
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
self.streaming_fn = streaming_fn
self.status = status
self.headers = headers or Header()
self.content_type = content_type
self.request: Optional[Request] = None
self._cookies: Optional[CookieJar] = None
async def write(self, message: str):
await self.response.send(message)
async def stream(self) -> HTTPResponse:
if not self.request:
raise ServerError("Attempted response to unknown request")
self.response = await self.request.respond(
headers=self.headers,
status=self.status,
content_type=self.content_type,
)
await self.streaming_fn(self)
return self.response
async def eof(self) -> None:
await self.response.eof()
@property
def cookies(self) -> CookieJar:
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self):
return self.response.processed_headers
@property
def body(self):
return self.response.body
def __call__(self, request: Request) -> ResponseStream:
self.request = request
return self
def __await__(self):
return self.stream().__await__()

View File

@@ -133,14 +133,14 @@ class Router(BaseRouter):
params.update({"requirements": {"host": host}})
route = super().add(**params) # type: ignore
route.ctx.ignore_body = ignore_body
route.ctx.stream = stream
route.ctx.hosts = hosts
route.ctx.static = static
route.ctx.error_format = error_format
route.extra.ignore_body = ignore_body
route.extra.stream = stream
route.extra.hosts = hosts
route.extra.static = static
route.extra.error_format = error_format
if error_format:
check_error_format(route.ctx.error_format)
check_error_format(route.extra.error_format)
routes.append(route)

View File

@@ -1,7 +1,13 @@
from typing import TYPE_CHECKING, Optional, Sequence, cast
from websockets.connection import CLOSED, CLOSING, OPEN
from websockets.server import ServerConnection
try: # websockets < 11.0
from websockets.connection import State
from websockets.server import ServerConnection as ServerProtocol
except ImportError: # websockets >= 11.0
from websockets.protocol import State # type: ignore
from websockets.server import ServerProtocol # type: ignore
from websockets.typing import Subprotocol
from sanic.exceptions import ServerError
@@ -15,6 +21,11 @@ if TYPE_CHECKING:
from websockets import http11
OPEN = State.OPEN
CLOSING = State.CLOSING
CLOSED = State.CLOSED
class WebSocketProtocol(HttpProtocol):
__slots__ = (
"websocket",
@@ -74,7 +85,7 @@ class WebSocketProtocol(HttpProtocol):
# Called by Sanic Server when shutting down
# If we've upgraded to websocket, shut it down
if self.websocket is not None:
if self.websocket.connection.state in (CLOSING, CLOSED):
if self.websocket.ws_proto.state in (CLOSING, CLOSED):
return True
elif self.websocket.loop is not None:
self.websocket.loop.create_task(self.websocket.close(1001))
@@ -90,7 +101,7 @@ class WebSocketProtocol(HttpProtocol):
try:
if subprotocols is not None:
# subprotocols can be a set or frozenset,
# but ServerConnection needs a list
# but ServerProtocol needs a list
subprotocols = cast(
Optional[Sequence[Subprotocol]],
list(
@@ -100,13 +111,13 @@ class WebSocketProtocol(HttpProtocol):
]
),
)
ws_conn = ServerConnection(
ws_proto = ServerProtocol(
max_size=self.websocket_max_size,
subprotocols=subprotocols,
state=OPEN,
logger=logger,
)
resp: "http11.Response" = ws_conn.accept(request)
resp: "http11.Response" = ws_proto.accept(request)
except Exception:
msg = (
"Failed to open a WebSocket connection.\n"
@@ -129,7 +140,7 @@ class WebSocketProtocol(HttpProtocol):
else:
raise ServerError(resp.body, resp.status_code)
self.websocket = WebsocketImplProtocol(
ws_conn,
ws_proto,
ping_interval=self.websocket_ping_interval,
ping_timeout=self.websocket_ping_timeout,
close_timeout=self.websocket_timeout,

View File

@@ -27,7 +27,7 @@ from signal import signal as signal_func
from sanic.application.ext import setup_ext
from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows
from sanic.http.http3 import SessionTicketStore, get_config
from sanic.log import error_logger, logger
from sanic.log import error_logger, server_logger
from sanic.models.server_types import Signal
from sanic.server.async_server import AsyncioServer
from sanic.server.protocols.http_protocol import Http3Protocol, HttpProtocol
@@ -149,12 +149,12 @@ def _setup_system_signals(
def _run_server_forever(loop, before_stop, after_stop, cleanup, unix):
pid = os.getpid()
try:
logger.info("Starting worker [%s]", pid)
server_logger.info("Starting worker [%s]", pid)
loop.run_forever()
except KeyboardInterrupt:
pass
finally:
logger.info("Stopping worker [%s]", pid)
server_logger.info("Stopping worker [%s]", pid)
loop.run_until_complete(before_stop())
@@ -372,7 +372,9 @@ def serve_multiple(server_settings, workers):
processes = []
def sig_handler(signal, frame):
logger.info("Received signal %s. Shutting down.", Signals(signal).name)
server_logger.info(
"Received signal %s. Shutting down.", Signals(signal).name
)
for process in processes:
os.kill(process.pid, SIGTERM)

View File

@@ -113,13 +113,16 @@ def configure_socket(
backlog=backlog,
)
except OSError as e: # no cov
raise ServerError(
f"Sanic server could not start: {e}.\n"
error = ServerError(
f"Sanic server could not start: {e}.\n\n"
"This may have happened if you are running Sanic in the "
"global scope and not inside of a "
'`if __name__ == "__main__"` block. See more information: '
"____."
) from e
'`if __name__ == "__main__"` block.\n\nSee more information: '
"https://sanic.dev/en/guide/deployment/manager.html#"
"how-sanic-server-starts-processes\n"
)
error.quiet = True
raise error
sock.set_inheritable(True)
server_settings["sock"] = sock
server_settings["host"] = None

View File

@@ -12,21 +12,37 @@ from typing import (
Union,
)
from websockets.connection import CLOSED, CLOSING, OPEN, Event
from websockets.exceptions import ConnectionClosed, ConnectionClosedError
from websockets.exceptions import (
ConnectionClosed,
ConnectionClosedError,
ConnectionClosedOK,
)
from websockets.frames import Frame, Opcode
from websockets.server import ServerConnection
try: # websockets < 11.0
from websockets.connection import Event, State
from websockets.server import ServerConnection as ServerProtocol
except ImportError: # websockets >= 11.0
from websockets.protocol import Event, State # type: ignore
from websockets.server import ServerProtocol # type: ignore
from websockets.typing import Data
from sanic.log import error_logger, logger
from sanic.log import deprecation, error_logger, logger
from sanic.server.protocols.base_protocol import SanicProtocol
from ...exceptions import ServerError, WebsocketClosed
from .frame import WebsocketFrameAssembler
OPEN = State.OPEN
CLOSING = State.CLOSING
CLOSED = State.CLOSED
class WebsocketImplProtocol:
connection: ServerConnection
ws_proto: ServerProtocol
io_proto: Optional[SanicProtocol]
loop: Optional[asyncio.AbstractEventLoop]
max_queue: int
@@ -52,14 +68,14 @@ class WebsocketImplProtocol:
def __init__(
self,
connection,
ws_proto,
max_queue=None,
ping_interval: Optional[float] = 20,
ping_timeout: Optional[float] = 20,
close_timeout: float = 10,
loop=None,
):
self.connection = connection
self.ws_proto = ws_proto
self.io_proto = None
self.loop = None
self.max_queue = max_queue
@@ -81,7 +97,16 @@ class WebsocketImplProtocol:
@property
def subprotocol(self):
return self.connection.subprotocol
return self.ws_proto.subprotocol
@property
def connection(self):
deprecation(
"The connection property has been deprecated and will be removed. "
"Please use the ws_proto property instead going forward.",
22.6,
)
return self.ws_proto
def pause_frames(self):
if not self.can_pause:
@@ -295,15 +320,15 @@ class WebsocketImplProtocol:
# Not draining the write buffer is acceptable in this context.
# clear the send buffer
_ = self.connection.data_to_send()
_ = self.ws_proto.data_to_send()
# If we're not already CLOSED or CLOSING, then send the close.
if self.connection.state is OPEN:
if self.ws_proto.state is OPEN:
if code in (1000, 1001):
self.connection.send_close(code, reason)
self.ws_proto.send_close(code, reason)
else:
self.connection.fail(code, reason)
self.ws_proto.fail(code, reason)
try:
data_to_send = self.connection.data_to_send()
data_to_send = self.ws_proto.data_to_send()
while (
len(data_to_send)
and self.io_proto
@@ -317,7 +342,7 @@ class WebsocketImplProtocol:
...
if code == 1006:
# Special case: 1006 consider the transport already closed
self.connection.state = CLOSED
self.ws_proto.state = CLOSED
if self.data_finished_fut and not self.data_finished_fut.done():
# We have a graceful auto-closer. Use it to close the connection.
self.data_finished_fut.cancel()
@@ -338,10 +363,10 @@ class WebsocketImplProtocol:
# In Python Version 3.7: pause_reading is idempotent
# i.e. it can be called when the transport is already paused or closed.
self.io_proto.transport.pause_reading()
if self.connection.state == OPEN:
data_to_send = self.connection.data_to_send()
self.connection.send_close(code, reason)
data_to_send.extend(self.connection.data_to_send())
if self.ws_proto.state == OPEN:
data_to_send = self.ws_proto.data_to_send()
self.ws_proto.send_close(code, reason)
data_to_send.extend(self.ws_proto.data_to_send())
try:
while (
len(data_to_send)
@@ -450,7 +475,7 @@ class WebsocketImplProtocol:
Raise ConnectionClosed in pending keepalive pings.
They'll never receive a pong once the connection is closed.
"""
if self.connection.state is not CLOSED:
if self.ws_proto.state is not CLOSED:
raise ServerError(
"Webscoket about_pings should only be called "
"after connection state is changed to CLOSED"
@@ -479,9 +504,9 @@ class WebsocketImplProtocol:
self.fail_connection(code, reason)
return
async with self.conn_mutex:
if self.connection.state is OPEN:
self.connection.send_close(code, reason)
data_to_send = self.connection.data_to_send()
if self.ws_proto.state is OPEN:
self.ws_proto.send_close(code, reason)
data_to_send = self.ws_proto.data_to_send()
await self.send_data(data_to_send)
async def recv(self, timeout: Optional[float] = None) -> Optional[Data]:
@@ -511,7 +536,7 @@ class WebsocketImplProtocol:
"already waiting for the next message"
)
await self.recv_lock.acquire()
if self.connection.state is CLOSED:
if self.ws_proto.state is CLOSED:
self.recv_lock.release()
raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed."
@@ -562,7 +587,7 @@ class WebsocketImplProtocol:
"for the next message"
)
await self.recv_lock.acquire()
if self.connection.state is CLOSED:
if self.ws_proto.state is CLOSED:
self.recv_lock.release()
raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed."
@@ -621,7 +646,7 @@ class WebsocketImplProtocol:
"is already waiting for the next message"
)
await self.recv_lock.acquire()
if self.connection.state is CLOSED:
if self.ws_proto.state is CLOSED:
self.recv_lock.release()
raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed."
@@ -662,7 +687,7 @@ class WebsocketImplProtocol:
"""
async with self.conn_mutex:
if self.connection.state in (CLOSED, CLOSING):
if self.ws_proto.state in (CLOSED, CLOSING):
raise WebsocketClosed(
"Cannot write to websocket interface after it is closed."
)
@@ -675,12 +700,12 @@ class WebsocketImplProtocol:
# strings and bytes-like objects are iterable.
if isinstance(message, str):
self.connection.send_text(message.encode("utf-8"))
await self.send_data(self.connection.data_to_send())
self.ws_proto.send_text(message.encode("utf-8"))
await self.send_data(self.ws_proto.data_to_send())
elif isinstance(message, (bytes, bytearray, memoryview)):
self.connection.send_binary(message)
await self.send_data(self.connection.data_to_send())
self.ws_proto.send_binary(message)
await self.send_data(self.ws_proto.data_to_send())
elif isinstance(message, Mapping):
# Catch a common mistake -- passing a dict to send().
@@ -709,7 +734,7 @@ class WebsocketImplProtocol:
(which will be encoded to UTF-8) or a bytes-like object.
"""
async with self.conn_mutex:
if self.connection.state in (CLOSED, CLOSING):
if self.ws_proto.state in (CLOSED, CLOSING):
raise WebsocketClosed(
"Cannot send a ping when the websocket interface "
"is closed."
@@ -737,8 +762,8 @@ class WebsocketImplProtocol:
self.pings[data] = self.io_proto.loop.create_future()
self.connection.send_ping(data)
await self.send_data(self.connection.data_to_send())
self.ws_proto.send_ping(data)
await self.send_data(self.ws_proto.data_to_send())
return asyncio.shield(self.pings[data])
@@ -750,15 +775,15 @@ class WebsocketImplProtocol:
be a string (which will be encoded to UTF-8) or a bytes-like object.
"""
async with self.conn_mutex:
if self.connection.state in (CLOSED, CLOSING):
if self.ws_proto.state in (CLOSED, CLOSING):
# Cannot send pong after transport is shutting down
return
if isinstance(data, str):
data = data.encode("utf-8")
elif isinstance(data, (bytearray, memoryview)):
data = bytes(data)
self.connection.send_pong(data)
await self.send_data(self.connection.data_to_send())
self.ws_proto.send_pong(data)
await self.send_data(self.ws_proto.data_to_send())
async def send_data(self, data_to_send):
for data in data_to_send:
@@ -780,7 +805,7 @@ class WebsocketImplProtocol:
SanicProtocol.close(self.io_proto, timeout=1.0)
async def async_data_received(self, data_to_send, events_to_process):
if self.connection.state in (OPEN, CLOSING) and len(data_to_send) > 0:
if self.ws_proto.state in (OPEN, CLOSING) and len(data_to_send) > 0:
# receiving data can generate data to send (eg, pong for a ping)
# send connection.data_to_send()
await self.send_data(data_to_send)
@@ -788,9 +813,9 @@ class WebsocketImplProtocol:
await self.process_events(events_to_process)
def data_received(self, data):
self.connection.receive_data(data)
data_to_send = self.connection.data_to_send()
events_to_process = self.connection.events_received()
self.ws_proto.receive_data(data)
data_to_send = self.ws_proto.data_to_send()
events_to_process = self.ws_proto.events_received()
if len(data_to_send) > 0 or len(events_to_process) > 0:
asyncio.create_task(
self.async_data_received(data_to_send, events_to_process)
@@ -799,7 +824,7 @@ class WebsocketImplProtocol:
async def async_eof_received(self, data_to_send, events_to_process):
# receiving EOF can generate data to send
# send connection.data_to_send()
if self.connection.state in (OPEN, CLOSING) and len(data_to_send) > 0:
if self.ws_proto.state in (OPEN, CLOSING) and len(data_to_send) > 0:
await self.send_data(data_to_send)
if len(events_to_process) > 0:
await self.process_events(events_to_process)
@@ -819,9 +844,9 @@ class WebsocketImplProtocol:
SanicProtocol.close(self.io_proto, timeout=1.0)
def eof_received(self) -> Optional[bool]:
self.connection.receive_eof()
data_to_send = self.connection.data_to_send()
events_to_process = self.connection.events_received()
self.ws_proto.receive_eof()
data_to_send = self.ws_proto.data_to_send()
events_to_process = self.ws_proto.events_received()
asyncio.create_task(
self.async_eof_received(data_to_send, events_to_process)
)
@@ -831,12 +856,19 @@ class WebsocketImplProtocol:
"""
The WebSocket Connection is Closed.
"""
if not self.connection.state == CLOSED:
if not self.ws_proto.state == CLOSED:
# signal to the websocket connection handler
# we've lost the connection
self.connection.fail(code=1006)
self.connection.state = CLOSED
self.ws_proto.fail(code=1006)
self.ws_proto.state = CLOSED
self.abort_pings()
if self.connection_lost_waiter:
self.connection_lost_waiter.set_result(None)
async def __aiter__(self):
try:
while True:
yield await self.recv()
except ConnectionClosedOK:
return

View File

@@ -44,7 +44,9 @@ class SharedContext(SimpleNamespace):
f"{Colors.YELLOW}with type {Colors.PURPLE}{type(value)} "
f"{Colors.YELLOW}was added to shared_ctx. It may not "
"not function as intended. Consider using the regular "
f"ctx. For more information, please see ____.{Colors.END}"
f"ctx.\nFor more information, please see https://sanic.dev/en"
"/guide/deployment/manager.html#using-shared-context-between-"
f"worker-processes.{Colors.END}"
)
@property

View File

@@ -73,7 +73,7 @@ class Inspector:
def state_to_json(self):
output = {"info": self.app_info}
output["workers"] = self._make_safe(dict(self.worker_state))
output["workers"] = self.make_safe(dict(self.worker_state))
return output
def reload(self):
@@ -84,10 +84,11 @@ class Inspector:
message = "__TERMINATE__"
self._publisher.send(message)
def _make_safe(self, obj: Dict[str, Any]) -> Dict[str, Any]:
@staticmethod
def make_safe(obj: Dict[str, Any]) -> Dict[str, Any]:
for key, value in obj.items():
if isinstance(value, dict):
obj[key] = self._make_safe(value)
obj[key] = Inspector.make_safe(value)
elif isinstance(value, datetime):
obj[key] = value.isoformat()
return obj

View File

@@ -5,18 +5,10 @@ import sys
from importlib import import_module
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Optional,
Type,
Union,
cast,
)
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast
from sanic.http.tls.creators import CertCreator, MkcertCreator, TrustmeCreator
from sanic.http.tls.context import process_to_context
from sanic.http.tls.creators import MkcertCreator, TrustmeCreator
if TYPE_CHECKING:
@@ -106,21 +98,30 @@ class AppLoader:
class CertLoader:
_creator_class: Type[CertCreator]
_creators = {
"mkcert": MkcertCreator,
"trustme": TrustmeCreator,
}
def __init__(self, ssl_data: Dict[str, Union[str, os.PathLike]]):
creator_name = ssl_data.get("creator")
if creator_name not in ("mkcert", "trustme"):
self._ssl_data = ssl_data
creator_name = cast(str, ssl_data.get("creator"))
self._creator_class = self._creators.get(creator_name)
if not creator_name:
return
if not self._creator_class:
raise RuntimeError(f"Unknown certificate creator: {creator_name}")
elif creator_name == "mkcert":
self._creator_class = MkcertCreator
elif creator_name == "trustme":
self._creator_class = TrustmeCreator
self._key = ssl_data["key"]
self._cert = ssl_data["cert"]
self._localhost = cast(str, ssl_data["localhost"])
def load(self, app: SanicApp):
if not self._creator_class:
return process_to_context(self._ssl_data)
creator = self._creator_class(app, self._key, self._cert)
return creator.generate_cert(self._localhost)

View File

@@ -1,12 +1,12 @@
import os
import sys
from signal import SIGINT, SIGTERM, Signals
from signal import signal as signal_func
from time import sleep
from typing import List, Optional
from sanic.compat import OS_IS_WINDOWS
from sanic.constants import RestartOrder
from sanic.exceptions import ServerKilled
from sanic.log import error_logger, logger
from sanic.worker.process import ProcessState, Worker, WorkerProcess
@@ -18,7 +18,8 @@ else:
class WorkerManager:
THRESHOLD = 50
THRESHOLD = 300 # == 30 seconds
MAIN_IDENT = "Sanic-Main"
def __init__(
self,
@@ -28,6 +29,7 @@ class WorkerManager:
context,
monitor_pubsub,
worker_state,
restart_order: RestartOrder = RestartOrder.SHUTDOWN_FIRST,
):
self.num_server = number
self.context = context
@@ -35,8 +37,9 @@ class WorkerManager:
self.durable: List[Worker] = []
self.monitor_publisher, self.monitor_subscriber = monitor_pubsub
self.worker_state = worker_state
self.worker_state["Sanic-Main"] = {"pid": self.pid}
self.worker_state[self.MAIN_IDENT] = {"pid": self.pid}
self.terminated = False
self.restart_order = restart_order
if number == 0:
raise RuntimeError("Cannot serve with no workers")
@@ -55,7 +58,14 @@ class WorkerManager:
def manage(self, ident, func, kwargs, transient=False):
container = self.transient if transient else self.durable
container.append(
Worker(ident, func, kwargs, self.context, self.worker_state)
Worker(
ident,
func,
kwargs,
self.context,
self.worker_state,
self.restart_order,
)
)
def run(self):
@@ -123,20 +133,50 @@ class WorkerManager:
process_names=process_names,
reloaded_files=reloaded_files,
)
self._sync_states()
except InterruptedError:
if not OS_IS_WINDOWS:
raise
break
def _sync_states(self):
for process in self.processes:
state = self.worker_state[process.name].get("state")
if state and process.state.name != state:
process.set_state(ProcessState[state], True)
def wait_for_ack(self): # no cov
misses = 0
message = (
"It seems that one or more of your workers failed to come "
"online in the allowed time. Sanic is shutting down to avoid a "
f"deadlock. The current threshold is {self.THRESHOLD / 10}s. "
"If this problem persists, please check out the documentation "
"___."
)
while not self._all_workers_ack():
sleep(0.1)
if self.monitor_subscriber.poll(0.1):
monitor_msg = self.monitor_subscriber.recv()
if monitor_msg != "__TERMINATE_EARLY__":
self.monitor_publisher.send(monitor_msg)
continue
misses = self.THRESHOLD
message = (
"One of your worker processes terminated before startup "
"was completed. Please solve any errors experienced "
"during startup. If you do not see an exception traceback "
"in your error logs, try running Sanic in in a single "
"process using --single-process or single_process=True. "
"Once you are confident that the server is able to start "
"without errors you can switch back to multiprocess mode."
)
misses += 1
if misses > self.THRESHOLD:
error_logger.error("Not all workers are ack. Shutting down.")
error_logger.error(
"Not all workers acknowledged a successful startup. "
"Shutting down.\n\n" + message
)
self.kill()
sys.exit(1)
@property
def workers(self):
@@ -156,7 +196,9 @@ class WorkerManager:
def kill(self):
for process in self.processes:
logger.info("Killing %s [%s]", process.name, process.pid)
os.kill(process.pid, SIGKILL)
raise ServerKilled
def shutdown_signal(self, signal, frame):
logger.info("Received signal %s. Shutting down.", Signals(signal).name)

View File

@@ -21,15 +21,21 @@ class WorkerMultiplexer:
"state": ProcessState.ACKED.name,
}
def restart(self, name: str = ""):
def restart(self, name: str = "", all_workers: bool = False):
if name and all_workers:
raise ValueError(
"Ambiguous restart with both a named process and"
" all_workers=True"
)
if not name:
name = self.name
name = "__ALL_PROCESSES__:" if all_workers else self.name
self._monitor_publisher.send(name)
reload = restart # no cov
def terminate(self):
self._monitor_publisher.send("__TERMINATE__")
def terminate(self, early: bool = False):
message = "__TERMINATE_EARLY__" if early else "__TERMINATE__"
self._monitor_publisher.send(message)
@property
def pid(self) -> int:

View File

@@ -4,8 +4,10 @@ from datetime import datetime, timezone
from enum import IntEnum, auto
from multiprocessing.context import BaseContext
from signal import SIGINT
from threading import Thread
from typing import Any, Dict, Set
from sanic.constants import RestartOrder
from sanic.log import Colors, logger
@@ -16,6 +18,8 @@ def get_now():
class ProcessState(IntEnum):
IDLE = auto()
RESTARTING = auto()
STARTING = auto()
STARTED = auto()
ACKED = auto()
JOINED = auto()
@@ -25,13 +29,22 @@ class ProcessState(IntEnum):
class WorkerProcess:
SERVER_LABEL = "Server"
def __init__(self, factory, name, target, kwargs, worker_state):
def __init__(
self,
factory,
name,
target,
kwargs,
worker_state,
restart_order: RestartOrder,
):
self.state = ProcessState.IDLE
self.factory = factory
self.name = name
self.target = target
self.kwargs = kwargs
self.worker_state = worker_state
self.restart_order = restart_order
if self.name not in self.worker_state:
self.worker_state[self.name] = {
"server": self.SERVER_LABEL in self.name
@@ -54,8 +67,9 @@ class WorkerProcess:
f"{Colors.SANIC}%s{Colors.END}",
self.name,
)
self.set_state(ProcessState.STARTING)
self._current_process.start()
self.set_state(ProcessState.STARTED)
self._process.start()
if not self.worker_state[self.name].get("starts"):
self.worker_state[self.name] = {
**self.worker_state[self.name],
@@ -67,7 +81,7 @@ class WorkerProcess:
def join(self):
self.set_state(ProcessState.JOINED)
self._process.join()
self._current_process.join()
def terminate(self):
if self.state is not ProcessState.TERMINATED:
@@ -80,7 +94,6 @@ class WorkerProcess:
)
self.set_state(ProcessState.TERMINATED, force=True)
try:
# self._process.terminate()
os.kill(self.pid, SIGINT)
del self.worker_state[self.name]
except (KeyError, AttributeError, ProcessLookupError):
@@ -93,8 +106,11 @@ class WorkerProcess:
self.name,
self.pid,
)
self._process.terminate()
self.set_state(ProcessState.IDLE, force=True)
self.set_state(ProcessState.RESTARTING, force=True)
if self.restart_order is RestartOrder.SHUTDOWN_FIRST:
self._terminate_now()
else:
self._old_process = self._current_process
self.kwargs.update(
{"config": {k.upper(): v for k, v in kwargs.items()}}
)
@@ -104,6 +120,9 @@ class WorkerProcess:
except AttributeError:
raise RuntimeError("Restart failed")
if self.restart_order is RestartOrder.STARTUP_FIRST:
self._terminate_soon()
self.worker_state[self.name] = {
**self.worker_state[self.name],
"pid": self.pid,
@@ -111,16 +130,59 @@ class WorkerProcess:
"restart_at": get_now(),
}
def _terminate_now(self):
logger.debug(
f"{Colors.BLUE}Begin restart termination: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._current_process.pid,
)
self._current_process.terminate()
def _terminate_soon(self):
logger.debug(
f"{Colors.BLUE}Begin restart termination: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._current_process.pid,
)
termination_thread = Thread(target=self._wait_to_terminate)
termination_thread.start()
def _wait_to_terminate(self):
logger.debug(
f"{Colors.BLUE}Waiting for process to be acked: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._old_process.pid,
)
# TODO: Add a timeout?
while self.state is not ProcessState.ACKED:
...
else:
logger.debug(
f"{Colors.BLUE}Process acked. Terminating: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._old_process.pid,
)
self._old_process.terminate()
delattr(self, "_old_process")
def is_alive(self):
try:
return self._process.is_alive()
return self._current_process.is_alive()
except AssertionError:
return False
def spawn(self):
if self.state is not ProcessState.IDLE:
if self.state not in (ProcessState.IDLE, ProcessState.RESTARTING):
raise Exception("Cannot spawn a worker process until it is idle.")
self._process = self.factory(
self._current_process = self.factory(
name=self.name,
target=self.target,
kwargs=self.kwargs,
@@ -129,10 +191,12 @@ class WorkerProcess:
@property
def pid(self):
return self._process.pid
return self._current_process.pid
class Worker:
WORKER_PREFIX = "Sanic-"
def __init__(
self,
ident: str,
@@ -140,22 +204,25 @@ class Worker:
server_settings,
context: BaseContext,
worker_state: Dict[str, Any],
restart_order: RestartOrder,
):
self.ident = ident
self.ident = f"{self.WORKER_PREFIX}{ident}"
self.context = context
self.serve = serve
self.server_settings = server_settings
self.worker_state = worker_state
self.processes: Set[WorkerProcess] = set()
self.restart_order = restart_order
self.create_process()
def create_process(self) -> WorkerProcess:
process = WorkerProcess(
factory=self.context.Process,
name=f"Sanic-{self.ident}-{len(self.processes)}",
name=f"{self.ident}-{len(self.processes)}",
target=self.serve,
kwargs={**self.server_settings},
worker_state=self.worker_state,
restart_order=self.restart_order,
)
self.processes.add(process)
return process

View File

@@ -9,6 +9,7 @@ from multiprocessing.connection import Connection
from pathlib import Path
from signal import SIGINT, SIGTERM
from signal import signal as signal_func
from time import sleep
from typing import Dict, Set
from sanic.server.events import trigger_events
@@ -62,6 +63,7 @@ class Reloader:
self.reload(",".join(changed) if changed else "unknown")
if after_trigger:
trigger_events(after_trigger, loop, app)
sleep(self.interval)
else:
if reloader_stop:
trigger_events(reloader_stop, loop, app)

View File

@@ -1,6 +1,7 @@
import asyncio
import os
import socket
import warnings
from functools import partial
from multiprocessing.connection import Connection
@@ -10,6 +11,7 @@ from typing import Any, Dict, List, Optional, Type, Union
from sanic.application.constants import ServerStage
from sanic.application.state import ApplicationServerInfo
from sanic.http.constants import HTTP
from sanic.log import error_logger
from sanic.models.server_types import Signal
from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.runners import _serve_http_1, _serve_http_3
@@ -45,80 +47,93 @@ def worker_serve(
config=None,
passthru: Optional[Dict[str, Any]] = None,
):
from sanic import Sanic
try:
from sanic import Sanic
if app_loader:
app = app_loader.load()
else:
app = Sanic.get_app(app_name)
if app_loader:
app = app_loader.load()
else:
app = Sanic.get_app(app_name)
app.refresh(passthru)
app.setup_loop()
app.refresh(passthru)
app.setup_loop()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Hydrate server info if needed
if server_info:
for app_name, server_info_objects in server_info.items():
a = Sanic.get_app(app_name)
if not a.state.server_info:
a.state.server_info = []
for info in server_info_objects:
if not info.settings.get("app"):
info.settings["app"] = a
a.state.server_info.append(info)
# Hydrate server info if needed
if server_info:
for app_name, server_info_objects in server_info.items():
a = Sanic.get_app(app_name)
if not a.state.server_info:
a.state.server_info = []
for info in server_info_objects:
if not info.settings.get("app"):
info.settings["app"] = a
a.state.server_info.append(info)
if isinstance(ssl, dict):
cert_loader = CertLoader(ssl)
ssl = cert_loader.load(app)
for info in app.state.server_info:
info.settings["ssl"] = ssl
if isinstance(ssl, dict):
cert_loader = CertLoader(ssl)
ssl = cert_loader.load(app)
for info in app.state.server_info:
info.settings["ssl"] = ssl
# When in a worker process, do some init
if os.environ.get("SANIC_WORKER_NAME"):
# Hydrate apps with any passed server info
# When in a worker process, do some init
if os.environ.get("SANIC_WORKER_NAME"):
# Hydrate apps with any passed server info
if monitor_publisher is None:
raise RuntimeError("No restart publisher found in worker process")
if worker_state is None:
raise RuntimeError("No worker state found in worker process")
if monitor_publisher is None:
raise RuntimeError(
"No restart publisher found in worker process"
)
if worker_state is None:
raise RuntimeError("No worker state found in worker process")
# Run secondary servers
apps = list(Sanic._app_registry.values())
app.before_server_start(partial(app._start_servers, apps=apps))
for a in apps:
a.multiplexer = WorkerMultiplexer(monitor_publisher, worker_state)
# Run secondary servers
apps = list(Sanic._app_registry.values())
app.before_server_start(partial(app._start_servers, apps=apps))
for a in apps:
a.multiplexer = WorkerMultiplexer(
monitor_publisher, worker_state
)
if app.debug:
loop.set_debug(app.debug)
if app.debug:
loop.set_debug(app.debug)
app.asgi = False
app.asgi = False
if app.state.server_info:
primary_server_info = app.state.server_info[0]
primary_server_info.stage = ServerStage.SERVING
if config:
app.update_config(config)
if app.state.server_info:
primary_server_info = app.state.server_info[0]
primary_server_info.stage = ServerStage.SERVING
if config:
app.update_config(config)
if version is HTTP.VERSION_3:
return _serve_http_3(host, port, app, loop, ssl)
return _serve_http_1(
host,
port,
app,
ssl,
sock,
unix,
reuse_port,
loop,
protocol,
backlog,
register_sys_signals,
run_multiple,
run_async,
connections,
signal,
state,
asyncio_server_kwargs,
)
if version is HTTP.VERSION_3:
return _serve_http_3(host, port, app, loop, ssl)
return _serve_http_1(
host,
port,
app,
ssl,
sock,
unix,
reuse_port,
loop,
protocol,
backlog,
register_sys_signals,
run_multiple,
run_async,
connections,
signal,
state,
asyncio_server_kwargs,
)
except Exception as e:
warnings.simplefilter("ignore", category=RuntimeWarning)
if monitor_publisher:
error_logger.exception(e)
multiplexer = WorkerMultiplexer(monitor_publisher, {})
multiplexer.terminate(True)
else:
raise e

View File

@@ -81,10 +81,10 @@ env_dependency = (
'; sys_platform != "win32" ' 'and implementation_name == "cpython"'
)
ujson = "ujson>=1.35" + env_dependency
uvloop = "uvloop>=0.5.3" + env_dependency
uvloop = "uvloop>=0.15.0" + env_dependency
types_ujson = "types-ujson" + env_dependency
requirements = [
"sanic-routing>=22.3.0,<22.6.0",
"sanic-routing>=22.8.0",
"httptools>=0.0.10",
uvloop,
ujson,
@@ -94,8 +94,8 @@ requirements = [
]
tests_require = [
"sanic-testing>=22.9.0b1",
"pytest",
"sanic-testing>=22.9.0",
"pytest==7.1.*",
"coverage",
"beautifulsoup4",
"pytest-sanic",

View File

@@ -15,9 +15,10 @@ from sanic import Sanic
from sanic.compat import OS_IS_WINDOWS
from sanic.config import Config
from sanic.exceptions import SanicException
from sanic.helpers import _default
from sanic.helpers import Default
from sanic.log import LOGGING_CONFIG_DEFAULTS
from sanic.response import text
from sanic.router import Route
@pytest.fixture(autouse=True)
@@ -152,8 +153,13 @@ def test_app_route_raise_value_error(app: Sanic):
def test_app_handle_request_handler_is_none(app: Sanic, monkeypatch):
app.config.TOUCHUP = False
route = Mock(spec=Route)
route.extra.request_middleware = []
route.extra.response_middleware = []
def mockreturn(*args, **kwargs):
return Mock(), None, {}
return route, None, {}
monkeypatch.setattr(app.router, "get", mockreturn)
@@ -341,7 +347,13 @@ def test_app_registry_retrieval_from_multiple():
def test_get_app_does_not_exist():
with pytest.raises(
SanicException, match='Sanic app name "does-not-exist" not found.'
SanicException,
match="Sanic app name 'does-not-exist' not found.\n"
"App instantiation must occur outside "
"if __name__ == '__main__' "
"block or by using an AppLoader.\nSee "
"https://sanic.dev/en/guide/deployment/app-loader.html"
" for more details.",
):
Sanic.get_app("does-not-exist")
@@ -485,7 +497,9 @@ def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
)
counter = Counter([(r[1], r[2]) for r in caplog.record_tuples])
modified = sum(1 for app in apps if app.config.USE_UVLOOP is not _default)
modified = sum(
1 for app in apps if not isinstance(app.config.USE_UVLOOP, Default)
)
assert counter[(logging.WARNING, message)] == modified
@@ -519,7 +533,7 @@ def test_multiple_uvloop_configs_display_warning(caplog):
counter = Counter([(r[1], r[2]) for r in caplog.record_tuples])
assert counter[(logging.WARNING, message)] == 2
assert counter[(logging.WARNING, message)] == 3
def test_cannot_run_fast_and_workers(app: Sanic):

View File

@@ -323,3 +323,20 @@ def test_bp_group_properties():
assert "api/v1/grouped/bp2/" in routes
assert "api/v1/primary/grouped/bp1" in routes
assert "api/v1/primary/grouped/bp2" in routes
def test_nested_bp_group_properties():
one = Blueprint("one", url_prefix="/one")
two = Blueprint.group(one)
three = Blueprint.group(two, url_prefix="/three")
@one.route("/four")
def handler(request):
return text("pi")
app = Sanic("PropTest")
app.blueprint(three)
app.router.finalize()
routes = [route.path for route in app.router.routes]
assert routes == ["three/one/four"]

View File

@@ -14,7 +14,7 @@ from pytest import MonkeyPatch
from sanic import Sanic
from sanic.config import DEFAULT_CONFIG, Config
from sanic.constants import LocalCertCreator
from sanic.constants import LocalCertCreator, RestartOrder
from sanic.exceptions import PyFileError
@@ -125,14 +125,9 @@ def test_env_w_custom_converter():
def test_env_lowercase():
with pytest.warns(None) as record:
environ["SANIC_test_answer"] = "42"
app = Sanic(name="Test")
assert app.config.test_answer == 42
assert str(record[0].message) == (
"[DEPRECATION v22.9] Lowercase environment variables will not be "
"loaded into Sanic config beginning in v22.9."
)
environ["SANIC_test_answer"] = "42"
app = Sanic(name="Test")
assert "test_answer" not in app.config
del environ["SANIC_test_answer"]
@@ -441,3 +436,19 @@ def test_convert_local_cert_creator(passed, expected):
app = Sanic("Test")
assert app.config.LOCAL_CERT_CREATOR is expected
del os.environ["SANIC_LOCAL_CERT_CREATOR"]
@pytest.mark.parametrize(
"passed,expected",
(
("shutdown_first", RestartOrder.SHUTDOWN_FIRST),
("startup_first", RestartOrder.STARTUP_FIRST),
("SHUTDOWN_FIRST", RestartOrder.SHUTDOWN_FIRST),
("STARTUP_FIRST", RestartOrder.STARTUP_FIRST),
),
)
def test_convert_restart_order(passed, expected):
os.environ["SANIC_RESTART_ORDER"] = passed
app = Sanic("Test")
assert app.config.RESTART_ORDER is expected
del os.environ["SANIC_RESTART_ORDER"]

View File

@@ -97,15 +97,15 @@ def test_auto_fallback_with_content_type(app):
def test_route_error_format_set_on_auto(app):
@app.get("/text")
def text_response(request):
return text(request.route.ctx.error_format)
return text(request.route.extra.error_format)
@app.get("/json")
def json_response(request):
return json({"format": request.route.ctx.error_format})
return json({"format": request.route.extra.error_format})
@app.get("/html")
def html_response(request):
return html(request.route.ctx.error_format)
return html(request.route.extra.error_format)
_, response = app.test_client.get("/text")
assert response.text == "text"

View File

@@ -10,7 +10,7 @@ import pytest
import sanic
from sanic import Sanic
from sanic.log import LOGGING_CONFIG_DEFAULTS, logger
from sanic.log import LOGGING_CONFIG_DEFAULTS, Colors, logger
from sanic.response import text
@@ -250,3 +250,14 @@ def test_verbosity(app, caplog, app_verbosity, log_verbosity, exists):
if app_verbosity == 0:
assert ("sanic.root", logging.INFO, "DEFAULT") in caplog.record_tuples
def test_colors_enum_format():
assert f"{Colors.END}" == Colors.END.value
assert f"{Colors.BOLD}" == Colors.BOLD.value
assert f"{Colors.BLUE}" == Colors.BLUE.value
assert f"{Colors.GREEN}" == Colors.GREEN.value
assert f"{Colors.PURPLE}" == Colors.PURPLE.value
assert f"{Colors.RED}" == Colors.RED.value
assert f"{Colors.SANIC}" == Colors.SANIC.value
assert f"{Colors.YELLOW}" == Colors.YELLOW.value

View File

@@ -1,6 +1,6 @@
import logging
from asyncio import CancelledError
from asyncio import CancelledError, sleep
from itertools import count
from sanic.exceptions import NotFound
@@ -318,6 +318,32 @@ def test_middleware_return_response(app):
resp1 = await request.respond()
return resp1
_, response = app.test_client.get("/")
app.test_client.get("/")
assert response_middleware_run_count == 1
assert request_middleware_run_count == 1
def test_middleware_run_on_timeout(app):
app.config.RESPONSE_TIMEOUT = 0.1
response_middleware_run_count = 0
request_middleware_run_count = 0
@app.on_response
def response(_, response):
nonlocal response_middleware_run_count
response_middleware_run_count += 1
@app.on_request
def request(_):
nonlocal request_middleware_run_count
request_middleware_run_count += 1
@app.get("/")
async def handler(request):
resp1 = await request.respond()
await sleep(1)
return resp1
app.test_client.get("/")
assert request_middleware_run_count == 1
assert response_middleware_run_count == 1

View File

@@ -0,0 +1,90 @@
from functools import partial
import pytest
from sanic import Sanic
from sanic.middleware import Middleware
from sanic.response import json
PRIORITY_TEST_CASES = (
([0, 1, 2], [1, 1, 1]),
([0, 1, 2], [1, 1, None]),
([0, 1, 2], [1, None, None]),
([0, 1, 2], [2, 1, None]),
([0, 1, 2], [2, 2, None]),
([0, 1, 2], [3, 2, 1]),
([0, 1, 2], [None, None, None]),
([0, 2, 1], [1, None, 1]),
([0, 2, 1], [2, None, 1]),
([0, 2, 1], [2, None, 2]),
([0, 2, 1], [3, 1, 2]),
([1, 0, 2], [1, 2, None]),
([1, 0, 2], [2, 3, 1]),
([1, 0, 2], [None, 1, None]),
([1, 2, 0], [1, 3, 2]),
([1, 2, 0], [None, 1, 1]),
([1, 2, 0], [None, 2, 1]),
([1, 2, 0], [None, 2, 2]),
([2, 0, 1], [1, None, 2]),
([2, 0, 1], [2, 1, 3]),
([2, 0, 1], [None, None, 1]),
([2, 1, 0], [1, 2, 3]),
([2, 1, 0], [None, 1, 2]),
)
@pytest.fixture(autouse=True)
def reset_middleware():
yield
Middleware.reset_count()
@pytest.mark.parametrize(
"expected,priorities",
PRIORITY_TEST_CASES,
)
def test_request_middleware_order_priority(app: Sanic, expected, priorities):
order = []
def add_ident(request, ident):
order.append(ident)
@app.get("/")
def handler(request):
return json(None)
for ident, priority in enumerate(priorities):
kwargs = {}
if priority is not None:
kwargs["priority"] = priority
app.on_request(partial(add_ident, ident=ident), **kwargs)
app.test_client.get("/")
assert order == expected
@pytest.mark.parametrize(
"expected,priorities",
PRIORITY_TEST_CASES,
)
def test_response_middleware_order_priority(app: Sanic, expected, priorities):
order = []
def add_ident(request, response, ident):
order.append(ident)
@app.get("/")
def handler(request):
return json(None)
for ident, priority in enumerate(priorities):
kwargs = {}
if priority is not None:
kwargs["priority"] = priority
app.on_response(partial(add_ident, ident=ident), **kwargs)
app.test_client.get("/")
assert order[::-1] == expected

View File

@@ -4,8 +4,8 @@ import os
import time
from collections import namedtuple
from datetime import datetime
from email.utils import formatdate
from datetime import datetime, timedelta
from email.utils import formatdate, parsedate_to_datetime
from logging import ERROR, LogRecord
from mimetypes import guess_type
from pathlib import Path
@@ -665,13 +665,11 @@ def test_multiple_responses(
with caplog.at_level(ERROR):
_, response = app.test_client.get("/4")
print(response.json)
assert response.status == 200
assert "foo" not in response.text
assert "one" in response.headers
assert response.headers["one"] == "one"
print(response.headers)
assert message_in_records(caplog.records, error_msg2)
with caplog.at_level(ERROR):
@@ -841,10 +839,10 @@ def test_file_validate(app: Sanic, static_file_directory: str):
time.sleep(1)
with open(file_path, "a") as f:
f.write("bar\n")
_, response = app.test_client.get(
"/validate", headers={"If-Modified-Since": last_modified}
)
assert response.status == 200
assert response.body == b"foo\nbar\n"
@@ -921,3 +919,28 @@ def test_file_validating_304_response(
)
assert response.status == 304
assert response.body == b""
@pytest.mark.parametrize(
"file_name", ["test.file", "decode me.txt", "python.png"]
)
def test_file_validating_304_response(
app: Sanic, file_name: str, static_file_directory: str
):
app.static("static", Path(static_file_directory) / file_name)
_, response = app.test_client.get("/static")
assert response.status == 200
assert response.body == get_file_content(static_file_directory, file_name)
last_modified = parsedate_to_datetime(response.headers["Last-Modified"])
last_modified += timedelta(seconds=1)
_, response = app.test_client.get(
"/static",
headers={
"if-modified-since": formatdate(
last_modified.timestamp(), usegmt=True
)
},
)
assert response.status == 304
assert response.body == b""

215
tests/test_response_json.py Normal file
View File

@@ -0,0 +1,215 @@
import json
from functools import partial
from unittest.mock import Mock
import pytest
from sanic import Request, Sanic
from sanic.exceptions import SanicException
from sanic.response import json as json_response
from sanic.response.types import JSONResponse
JSON_BODY = {"ok": True}
json_dumps = partial(json.dumps, separators=(",", ":"))
@pytest.fixture
def json_app(app: Sanic):
@app.get("/json")
async def handle(request: Request):
return json_response(JSON_BODY)
return app
def test_body_can_be_retrieved(json_app: Sanic):
_, resp = json_app.test_client.get("/json")
assert resp.body == json_dumps(JSON_BODY).encode()
def test_body_can_be_set(json_app: Sanic):
new_body = b'{"hello":"world"}'
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.body = new_body
_, resp = json_app.test_client.get("/json")
assert resp.body == new_body
def test_raw_body_can_be_retrieved(json_app: Sanic):
@json_app.on_response
def check_body(request: Request, response: JSONResponse):
assert response.raw_body == JSON_BODY
json_app.test_client.get("/json")
def test_raw_body_can_be_set(json_app: Sanic):
new_body = {"hello": "world"}
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.raw_body = new_body
assert response.raw_body == new_body
assert response.body == json_dumps(new_body).encode()
json_app.test_client.get("/json")
def test_raw_body_cant_be_retrieved_after_body_set(json_app: Sanic):
new_body = b'{"hello":"world"}'
@json_app.on_response
def check_raw_body(request: Request, response: JSONResponse):
response.body = new_body
with pytest.raises(SanicException):
response.raw_body
json_app.test_client.get("/json")
def test_raw_body_can_be_reset_after_body_set(json_app: Sanic):
new_body = b'{"hello":"world"}'
new_new_body = {"lorem": "ipsum"}
@json_app.on_response
def set_bodies(request: Request, response: JSONResponse):
response.body = new_body
response.raw_body = new_new_body
_, resp = json_app.test_client.get("/json")
assert resp.body == json_dumps(new_new_body).encode()
def test_set_body_method(json_app: Sanic):
new_body = {"lorem": "ipsum"}
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.set_body(new_body)
_, resp = json_app.test_client.get("/json")
assert resp.body == json_dumps(new_body).encode()
def test_set_body_method_after_body_set(json_app: Sanic):
new_body = b'{"hello":"world"}'
new_new_body = {"lorem": "ipsum"}
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.body = new_body
response.set_body(new_new_body)
_, resp = json_app.test_client.get("/json")
assert resp.body == json_dumps(new_new_body).encode()
def test_custom_dumps_and_kwargs(json_app: Sanic):
custom_dumps = Mock(return_value="custom")
@json_app.get("/json-custom")
async def handle_custom(request: Request):
return json_response(JSON_BODY, dumps=custom_dumps, prry="platypus")
_, resp = json_app.test_client.get("/json-custom")
assert resp.body == "custom".encode()
custom_dumps.assert_called_once_with(JSON_BODY, prry="platypus")
def test_override_dumps_and_kwargs(json_app: Sanic):
custom_dumps_1 = Mock(return_value="custom1")
custom_dumps_2 = Mock(return_value="custom2")
@json_app.get("/json-custom")
async def handle_custom(request: Request):
return json_response(JSON_BODY, dumps=custom_dumps_1, prry="platypus")
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.set_body(JSON_BODY, dumps=custom_dumps_2, platypus="prry")
_, resp = json_app.test_client.get("/json-custom")
assert resp.body == "custom2".encode()
custom_dumps_1.assert_called_once_with(JSON_BODY, prry="platypus")
custom_dumps_2.assert_called_once_with(JSON_BODY, platypus="prry")
def test_append(json_app: Sanic):
@json_app.get("/json-append")
async def handler_append(request: Request):
return json_response(["a", "b"], status=200)
@json_app.on_response
def do_append(request: Request, response: JSONResponse):
response.append("c")
_, resp = json_app.test_client.get("/json-append")
assert resp.body == json_dumps(["a", "b", "c"]).encode()
def test_extend(json_app: Sanic):
@json_app.get("/json-extend")
async def handler_extend(request: Request):
return json_response(["a", "b"], status=200)
@json_app.on_response
def do_extend(request: Request, response: JSONResponse):
response.extend(["c", "d"])
_, resp = json_app.test_client.get("/json-extend")
assert resp.body == json_dumps(["a", "b", "c", "d"]).encode()
def test_update(json_app: Sanic):
@json_app.get("/json-update")
async def handler_update(request: Request):
return json_response({"a": "b"}, status=200)
@json_app.on_response
def do_update(request: Request, response: JSONResponse):
response.update({"c": "d"}, e="f")
_, resp = json_app.test_client.get("/json-update")
assert resp.body == json_dumps({"a": "b", "c": "d", "e": "f"}).encode()
def test_pop_dict(json_app: Sanic):
@json_app.get("/json-pop")
async def handler_pop(request: Request):
return json_response({"a": "b", "c": "d"}, status=200)
@json_app.on_response
def do_pop(request: Request, response: JSONResponse):
val = response.pop("c")
assert val == "d"
val_default = response.pop("e", "f")
assert val_default == "f"
_, resp = json_app.test_client.get("/json-pop")
assert resp.body == json_dumps({"a": "b"}).encode()
def test_pop_list(json_app: Sanic):
@json_app.get("/json-pop")
async def handler_pop(request: Request):
return json_response(["a", "b"], status=200)
@json_app.on_response
def do_pop(request: Request, response: JSONResponse):
val = response.pop(0)
assert val == "a"
with pytest.raises(
TypeError, match="pop doesn't accept a default argument for lists"
):
response.pop(21, "nah nah")
_, resp = json_app.test_client.get("/json-pop")
assert resp.body == json_dumps(["b"]).encode()

View File

@@ -503,9 +503,10 @@ def test_stack_trace_on_not_found(app, static_file_directory, caplog):
counter = Counter([(r[0], r[1]) for r in caplog.record_tuples])
assert response.status == 404
assert counter[("sanic.root", logging.INFO)] == 11
assert counter[("sanic.root", logging.INFO)] == 9
assert counter[("sanic.root", logging.ERROR)] == 0
assert counter[("sanic.error", logging.ERROR)] == 0
assert counter[("sanic.server", logging.INFO)] == 2
def test_no_stack_trace_on_not_found(app, static_file_directory, caplog):
@@ -521,9 +522,10 @@ def test_no_stack_trace_on_not_found(app, static_file_directory, caplog):
counter = Counter([(r[0], r[1]) for r in caplog.record_tuples])
assert response.status == 404
assert counter[("sanic.root", logging.INFO)] == 11
assert counter[("sanic.root", logging.INFO)] == 9
assert counter[("sanic.root", logging.ERROR)] == 0
assert counter[("sanic.error", logging.ERROR)] == 0
assert counter[("sanic.server", logging.INFO)] == 2
assert response.text == "No file: /static/non_existing_file.file"

View File

@@ -4,6 +4,7 @@ import ssl
import subprocess
from contextlib import contextmanager
from multiprocessing import Event
from pathlib import Path
from unittest.mock import Mock, patch
from urllib.parse import urlparse
@@ -264,6 +265,7 @@ def test_cert_sni_list(app):
assert response.text == "sanic.example"
@pytest.mark.xfail
def test_missing_sni(app):
"""The sanic cert does not list 127.0.0.1 and httpx does not send
IP as SNI anyway."""
@@ -282,6 +284,7 @@ def test_missing_sni(app):
assert "Request and response object expected" in str(exc.value)
@pytest.mark.xfail
def test_no_matching_cert(app):
"""The sanic cert does not list 127.0.0.1 and httpx does not send
IP as SNI anyway."""
@@ -301,6 +304,7 @@ def test_no_matching_cert(app):
assert "Request and response object expected" in str(exc.value)
@pytest.mark.xfail
def test_wildcards(app):
ssl_list = [None, localhost_dir, sanic_dir]
@@ -636,3 +640,29 @@ def test_sanic_ssl_context_create():
assert sanic_context is context
assert isinstance(sanic_context, SanicSSLContext)
def test_ssl_in_multiprocess_mode(app: Sanic, caplog):
ssl_dict = {"cert": localhost_cert, "key": localhost_key}
event = Event()
@app.main_process_start
async def main_start(app: Sanic):
app.shared_ctx.event = event
@app.after_server_start
async def shutdown(app):
app.shared_ctx.event.set()
app.stop()
assert not event.is_set()
with caplog.at_level(logging.INFO):
app.run(ssl=ssl_dict)
assert event.is_set()
assert (
"sanic.root",
logging.INFO,
"Goin' Fast @ https://127.0.0.1:8000",
) in caplog.record_tuples

View File

@@ -2,7 +2,7 @@
import logging
import os
from asyncio import AbstractEventLoop
from asyncio import AbstractEventLoop, sleep
from string import ascii_lowercase
import httpcore
@@ -179,6 +179,7 @@ async def client(app: Sanic, loop: AbstractEventLoop):
assert r.status_code == 200
assert r.text == os.path.abspath(SOCKPATH)
finally:
await sleep(0.2)
app.stop()

56
tests/test_ws_handlers.py Normal file
View File

@@ -0,0 +1,56 @@
from typing import Any, Callable, Coroutine
import pytest
from websockets.client import WebSocketClientProtocol
from sanic import Request, Sanic, Websocket
MimicClientType = Callable[
[WebSocketClientProtocol], Coroutine[None, None, Any]
]
@pytest.fixture
def simple_ws_mimic_client():
async def client_mimic(ws: WebSocketClientProtocol):
await ws.send("test 1")
await ws.recv()
await ws.send("test 2")
await ws.recv()
return client_mimic
def test_ws_handler(
app: Sanic,
simple_ws_mimic_client: MimicClientType,
):
@app.websocket("/ws")
async def ws_echo_handler(request: Request, ws: Websocket):
while True:
msg = await ws.recv()
await ws.send(msg)
_, ws_proxy = app.test_client.websocket(
"/ws", mimic=simple_ws_mimic_client
)
assert ws_proxy.client_sent == ["test 1", "test 2", ""]
assert ws_proxy.client_received == ["test 1", "test 2"]
def test_ws_handler_async_for(
app: Sanic,
simple_ws_mimic_client: MimicClientType,
):
@app.websocket("/ws")
async def ws_echo_handler(request: Request, ws: Websocket):
async for msg in ws:
await ws.send(msg)
_, ws_proxy = app.test_client.websocket(
"/ws", mimic=simple_ws_mimic_client
)
assert ws_proxy.client_sent == ["test 1", "test 2", ""]
assert ws_proxy.client_received == ["test 1", "test 2"]

View File

@@ -86,6 +86,10 @@ def test_input_is_module():
@patch("sanic.worker.loader.TrustmeCreator")
@patch("sanic.worker.loader.MkcertCreator")
def test_cert_loader(MkcertCreator: Mock, TrustmeCreator: Mock, creator: str):
CertLoader._creators = {
"mkcert": MkcertCreator,
"trustme": TrustmeCreator,
}
MkcertCreator.return_value = MkcertCreator
TrustmeCreator.return_value = TrustmeCreator
data = {

View File

@@ -3,6 +3,7 @@ from unittest.mock import Mock, call, patch
import pytest
from sanic.exceptions import ServerKilled
from sanic.worker.manager import WorkerManager
@@ -76,7 +77,8 @@ def test_kill(os_mock: Mock):
(Mock(), Mock()),
{},
)
manager.kill()
with pytest.raises(ServerKilled):
manager.kill()
os_mock.kill.assert_called_once_with(1234, SIGKILL)

View File

@@ -1,6 +1,6 @@
from multiprocessing import Event
from os import environ, getpid
from typing import Any, Dict
from typing import Any, Dict, Type, Union
from unittest.mock import Mock
import pytest
@@ -117,3 +117,26 @@ def test_properties(
assert m.workers == worker_state
assert m.state == worker_state["Test"]
assert isinstance(m.state, WorkerState)
@pytest.mark.parametrize(
"params,expected",
(
({}, "Test"),
({"name": "foo"}, "foo"),
({"all_workers": True}, "__ALL_PROCESSES__:"),
({"name": "foo", "all_workers": True}, ValueError),
),
)
def test_restart_params(
monitor_publisher: Mock,
m: WorkerMultiplexer,
params: Dict[str, Any],
expected: Union[str, Type[Exception]],
):
if isinstance(expected, str):
m.restart(**params)
monitor_publisher.send.assert_called_once_with(expected)
else:
with pytest.raises(expected):
m.restart(**params)

View File

@@ -1,13 +1,18 @@
import re
import signal
import threading
from asyncio import Event
from logging import DEBUG
from pathlib import Path
from unittest.mock import Mock
import pytest
from sanic.app import Sanic
from sanic.constants import RestartOrder
from sanic.worker.loader import AppLoader
from sanic.worker.process import ProcessState, WorkerProcess
from sanic.worker.reloader import Reloader
@@ -67,6 +72,64 @@ def test_iter_files():
assert len_total_files == len_python_files + len_static_files
@pytest.mark.parametrize(
"order,expected",
(
(
"shutdown_first",
[
"Restarting a process",
"Begin restart termination",
"Starting a process",
],
),
(
"startup_first",
[
"Restarting a process",
"Starting a process",
"Begin restart termination",
"Waiting for process to be acked",
"Process acked. Terminating",
],
),
),
)
def test_default_reload_shutdown_order(monkeypatch, caplog, order, expected):
current_process = Mock()
worker_process = WorkerProcess(
lambda **_: current_process,
"Test",
lambda **_: ...,
{},
{},
RestartOrder[order.upper()],
)
def start(self):
worker_process.set_state(ProcessState.ACKED)
self._target()
orig = threading.Thread.start
monkeypatch.setattr(threading.Thread, "start", start)
with caplog.at_level(DEBUG):
worker_process.restart()
ansi = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def clean(msg: str):
msg, _ = ansi.sub("", msg).split(":", 1)
return msg
debug = [clean(record[2]) for record in caplog.record_tuples]
assert debug == expected
current_process.start.assert_called_once()
current_process.terminate.assert_called_once()
monkeypatch.setattr(threading.Thread, "start", orig)
def test_reloader_triggers_start_stop_listeners(
app: Sanic, app_loader: AppLoader
):

View File

@@ -1,3 +1,5 @@
import logging
from os import environ
from unittest.mock import Mock, patch
@@ -37,7 +39,7 @@ def test_config_app(mock_app: Mock):
mock_app.update_config.assert_called_once_with({"FOO": "BAR"})
def test_bad_process(mock_app: Mock):
def test_bad_process(mock_app: Mock, caplog):
environ["SANIC_WORKER_NAME"] = "FOO"
message = "No restart publisher found in worker process"
@@ -45,8 +47,12 @@ def test_bad_process(mock_app: Mock):
worker_serve(**args(mock_app))
message = "No worker state found in worker process"
with pytest.raises(RuntimeError, match=message):
worker_serve(**args(mock_app, monitor_publisher=Mock()))
publisher = Mock()
with caplog.at_level(logging.ERROR):
worker_serve(**args(mock_app, monitor_publisher=publisher))
assert ("sanic.error", logging.ERROR, message) in caplog.record_tuples
publisher.send.assert_called_once_with("__TERMINATE_EARLY__")
del environ["SANIC_WORKER_NAME"]