Compare commits

..

1 Commits

Author SHA1 Message Date
Néstor Pérez
fc82b2334b Fix JSONResponse default content type (#2738)
Fix JSONResponse default content type (#2737)
2023-07-10 11:57:27 +03:00
99 changed files with 2263 additions and 4879 deletions

View File

@@ -9,7 +9,6 @@ omit =
sanic/simple.py
sanic/utils.py
sanic/cli
sanic/pages
[html]
directory = coverage

View File

@@ -1,33 +0,0 @@
name: 💡 Request for Comments
description: Open an RFC for discussion
labels: ["RFC"]
body:
- type: input
id: compare
attributes:
label: Link to code
description: If available, share a [comparison](https://github.com/sanic-org/sanic/compare) from a POC branch to main
placeholder: https://github.com/sanic-org/sanic/compare/main...some-new-branch
validations:
required: false
- type: textarea
id: proposal
attributes:
label: Proposal
description: A thorough discussion of the proposal discussing the problem it solves, potential code, use cases, and impacts
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Add any other context that is relevant
validations:
required: false
- type: checkboxes
id: breaking
attributes:
label: Is this a breaking change?
options:
- label: "Yes"
required: false

View File

@@ -316,6 +316,8 @@ Version 21.3.0
Version 20.12.3
---------------
`Current LTS version`
**Bugfixes**
*

View File

@@ -17,8 +17,7 @@ ignore:
- "sanic/compat.py"
- "sanic/simple.py"
- "sanic/utils.py"
- "sanic/cli/"
- "sanic/pages/"
- "sanic/cli"
- ".github/"
- "changelogs/"
- "docker/"

View File

@@ -1,11 +1,6 @@
📜 Changelog
============
| 🔶 Current release
| 🔷 In support release
|
.. mdinclude:: ./releases/23/23.3.md
.. mdinclude:: ./releases/22/22.12.md
.. mdinclude:: ./releases/22/22.9.md
.. mdinclude:: ./releases/22/22.6.md

View File

@@ -1,4 +1,4 @@
## Version 22.12.0 🔷
## Version 22.12.0 🔶
_Current version_

View File

@@ -1,53 +0,0 @@
## Version 23.3.0 🔶
### Features
- [#2545](https://github.com/sanic-org/sanic/pull/2545) Standardize init of exceptions for more consistent control of HTTP responses using exceptions
- [#2606](https://github.com/sanic-org/sanic/pull/2606) Decode headers as UTF-8 also in ASGI
- [#2646](https://github.com/sanic-org/sanic/pull/2646) Separate ASGI request and lifespan callables
- [#2659](https://github.com/sanic-org/sanic/pull/2659) Use ``FALLBACK_ERROR_FORMAT`` for handlers that return ``empty()``
- [#2662](https://github.com/sanic-org/sanic/pull/2662) Add basic file browser (HTML page) and auto-index serving
- [#2667](https://github.com/sanic-org/sanic/pull/2667) Nicer traceback formatting (HTML page)
- [#2668](https://github.com/sanic-org/sanic/pull/2668) Smarter error page rendering format selection; more reliant upon header and "common sense" defaults
- [#2680](https://github.com/sanic-org/sanic/pull/2680) Check the status of socket before shutting down with ``SHUT_RDWR``
- [#2687](https://github.com/sanic-org/sanic/pull/2687) Refresh ``Request.accept`` functionality to be more performant and spec-compliant
- [#2696](https://github.com/sanic-org/sanic/pull/2696) Add header accessors as properties
```
Example-Field: Foo, Bar
Example-Field: Baz
```
```python
request.headers.example_field == "Foo, Bar,Baz"
```
- [#2700](https://github.com/sanic-org/sanic/pull/2700) Simpler CLI targets
```sh
$ sanic path.to.module:app # global app instance
$ sanic path.to.module:create_app # factory pattern
$ sanic ./path/to/directory/ # simple serve
```
- [#2701](https://github.com/sanic-org/sanic/pull/2701) API to define a number of workers in managed processes
- [#2704](https://github.com/sanic-org/sanic/pull/2704) Add convenience for dynamic changes to routing
- [#2706](https://github.com/sanic-org/sanic/pull/2706) Add convenience methods for cookie creation and deletion
```python
response = text("...")
response.add_cookie("test", "It worked!", domain=".yummy-yummy-cookie.com")
```
- [#2707](https://github.com/sanic-org/sanic/pull/2707) Simplified ``parse_content_header`` escaping to be RFC-compliant and remove outdated FF hack
- [#2710](https://github.com/sanic-org/sanic/pull/2710) Stricter charset handling and escaping of request URLs
- [#2711](https://github.com/sanic-org/sanic/pull/2711) Consume body on ``DELETE`` by default
- [#2719](https://github.com/sanic-org/sanic/pull/2719) Allow ``password`` to be passed to TLS context
- [#2720](https://github.com/sanic-org/sanic/pull/2720) Skip middleware on ``RequestCancelled``
- [#2721](https://github.com/sanic-org/sanic/pull/2721) Change access logging format to ``%s``
- [#2722](https://github.com/sanic-org/sanic/pull/2722) Add ``CertLoader`` as application option for directly controlling ``SSLContext`` objects
- [#2725](https://github.com/sanic-org/sanic/pull/2725) Worker sync state tolerance on race condition
### Bugfixes
- [#2651](https://github.com/sanic-org/sanic/pull/2651) ASGI websocket to pass thru bytes as is
- [#2697](https://github.com/sanic-org/sanic/pull/2697) Fix comparison between datetime aware and naive in ``file`` when using ``If-Modified-Since``
### Deprecations and Removals
- [#2666](https://github.com/sanic-org/sanic/pull/2666) Remove deprecated ``__blueprintname__`` property
### Improved Documentation
- [#2712](https://github.com/sanic-org/sanic/pull/2712) Improved example using ``'https'`` to create the redirect

View File

@@ -29,7 +29,7 @@ def proxy(request, path):
path=path,
_server=https.config.SERVER_NAME,
_external=True,
_scheme="https",
_scheme="http",
)
return response.redirect(url)

View File

@@ -23,7 +23,5 @@ module = [
"trustme.*",
"sanic_routing.*",
"aioquic.*",
"html5tagger.*",
"tracerite.*",
]
ignore_missing_imports = true

View File

@@ -2,22 +2,6 @@ from sanic.__version__ import __version__
from sanic.app import Sanic
from sanic.blueprints import Blueprint
from sanic.constants import HTTPMethod
from sanic.exceptions import (
BadRequest,
ExpectationFailed,
FileNotFound,
Forbidden,
HeaderNotFound,
InternalServerError,
InvalidHeader,
MethodNotAllowed,
NotFound,
RangeNotSatisfiable,
SanicException,
ServerError,
ServiceUnavailable,
Unauthorized,
)
from sanic.request import Request
from sanic.response import (
HTTPResponse,
@@ -25,7 +9,6 @@ from sanic.response import (
file,
html,
json,
raw,
redirect,
text,
)
@@ -34,34 +17,16 @@ from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket
__all__ = (
"__version__",
# Common objects
"Sanic",
"Blueprint",
"HTTPMethod",
"HTTPResponse",
"Request",
"Websocket",
# Common exceptions
"BadRequest",
"ExpectationFailed",
"FileNotFound",
"Forbidden",
"HeaderNotFound",
"InternalServerError",
"InvalidHeader",
"MethodNotAllowed",
"NotFound",
"RangeNotSatisfiable",
"SanicException",
"ServerError",
"ServiceUnavailable",
"Unauthorized",
# Common response methods
"empty",
"file",
"html",
"json",
"raw",
"redirect",
"text",
)

View File

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

View File

@@ -16,7 +16,7 @@ from asyncio import (
)
from asyncio.futures import Future
from collections import defaultdict, deque
from contextlib import contextmanager, suppress
from contextlib import suppress
from functools import partial
from inspect import isawaitable
from os import environ
@@ -33,7 +33,6 @@ from typing import (
Deque,
Dict,
Iterable,
Iterator,
List,
Optional,
Set,
@@ -49,7 +48,7 @@ from sanic_routing.route import Route
from sanic.application.ext import setup_ext
from sanic.application.state import ApplicationState, ServerStage
from sanic.asgi import ASGIApp, Lifespan
from sanic.asgi import ASGIApp
from sanic.base.root import BaseSanic
from sanic.blueprint_group import BlueprintGroup
from sanic.blueprints import Blueprint
@@ -64,11 +63,15 @@ from sanic.exceptions import (
from sanic.handlers import ErrorHandler
from sanic.helpers import Default, _default
from sanic.http import Stage
from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger
from sanic.log import (
LOGGING_CONFIG_DEFAULTS,
deprecation,
error_logger,
logger,
)
from sanic.middleware import Middleware, MiddlewareLocation
from sanic.mixins.listeners import ListenerEvent
from sanic.mixins.startup import StartupMixin
from sanic.mixins.static import StaticHandleMixin
from sanic.models.futures import (
FutureException,
FutureListener,
@@ -76,6 +79,7 @@ from sanic.models.futures import (
FutureRegistry,
FutureRoute,
FutureSignal,
FutureStatic,
)
from sanic.models.handler_types import ListenerType, MiddlewareType
from sanic.models.handler_types import Sanic as SanicVar
@@ -87,7 +91,6 @@ from sanic.signals import Signal, SignalRouter
from sanic.touchup import TouchUp, TouchUpMeta
from sanic.types.shared_ctx import SharedContext
from sanic.worker.inspector import Inspector
from sanic.worker.loader import CertLoader
from sanic.worker.manager import WorkerManager
@@ -103,7 +106,7 @@ if OS_IS_WINDOWS: # no cov
enable_windows_color_support()
class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"""
The main application instance
"""
@@ -116,7 +119,6 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
)
__slots__ = (
"_asgi_app",
"_asgi_lifespan",
"_asgi_client",
"_blueprint_order",
"_delayed_tasks",
@@ -135,7 +137,6 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"_test_client",
"_test_manager",
"blueprints",
"certloader_class",
"config",
"configure_logging",
"ctx",
@@ -178,7 +179,6 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None:
super().__init__(name=name)
# logging
@@ -198,8 +198,6 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
self.config.INSPECTOR = inspector
# Then we can do the rest
self._asgi_app: Optional[ASGIApp] = None
self._asgi_lifespan: Optional[Lifespan] = None
self._asgi_client: Any = None
self._blueprint_order: List[Blueprint] = []
self._delayed_tasks: List[str] = []
@@ -213,9 +211,6 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
self.asgi = False
self.auto_reload = False
self.blueprints: Dict[str, Blueprint] = {}
self.certloader_class: Type[CertLoader] = (
certloader_class or CertLoader
)
self.configure_logging: bool = configure_logging
self.ctx: Any = ctx or SimpleNamespace()
self.error_handler: ErrorHandler = error_handler or ErrorHandler()
@@ -435,36 +430,36 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
ctx = params.pop("route_context")
with self.amend():
routes = self.router.add(**params)
if isinstance(routes, Route):
routes = [routes]
routes = self.router.add(**params)
if isinstance(routes, Route):
routes = [routes]
for r in routes:
r.extra.websocket = websocket
r.extra.static = params.get("static", False)
r.ctx.__dict__.update(ctx)
for r in routes:
r.extra.websocket = websocket
r.extra.static = params.get("static", False)
r.ctx.__dict__.update(ctx)
return routes
def _apply_static(self, static: FutureStatic) -> Route:
return self._register_static(static)
def _apply_middleware(
self,
middleware: FutureMiddleware,
route_names: Optional[List[str]] = None,
):
with self.amend():
if route_names:
return self.register_named_middleware(
middleware.middleware, route_names, middleware.attach_to
)
else:
return self.register_middleware(
middleware.middleware, middleware.attach_to
)
if route_names:
return self.register_named_middleware(
middleware.middleware, route_names, middleware.attach_to
)
else:
return self.register_middleware(
middleware.middleware, middleware.attach_to
)
def _apply_signal(self, signal: FutureSignal) -> Signal:
with self.amend():
return self.signal_router.add(*signal)
return self.signal_router.add(*signal)
def dispatch(
self,
@@ -883,8 +878,6 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
:param request: HTTP Request object
:return: Nothing
"""
__tracebackhide__ = True
await self.dispatch(
"http.lifecycle.handle",
inline=True,
@@ -897,7 +890,6 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
Union[
BaseHTTPResponse,
Coroutine[Any, Any, Optional[BaseHTTPResponse]],
ResponseStream,
]
] = None
run_middleware = True
@@ -1006,7 +998,7 @@ class Sanic(StaticHandleMixin, 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,
@@ -1015,7 +1007,7 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"response": resp,
},
)
await response.eof()
await response.eof() # type: ignore
else:
if not hasattr(handler, "is_websocket"):
raise ServerError(
@@ -1357,14 +1349,12 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
three arguments: scope, receive, send. See the ASGI reference for more
details: https://asgi.readthedocs.io/en/latest
"""
self.asgi = True
if scope["type"] == "lifespan":
self.asgi = True
self.motd("")
self._asgi_lifespan = Lifespan(self, scope, receive, send)
await self._asgi_lifespan()
else:
self._asgi_app = await ASGIApp.create(self, scope, receive, send)
await self._asgi_app()
self._asgi_app = await ASGIApp.create(self, scope, receive, send)
asgi_app = self._asgi_app
await asgi_app()
_asgi_single_callable = True # We conform to ASGI 3.0 single-callable
@@ -1525,27 +1515,6 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
# Lifecycle
# -------------------------------------------------------------------- #
@contextmanager
def amend(self) -> Iterator[None]:
"""
If the application has started, this function allows changes
to be made to add routes, middleware, and signals.
"""
if not self.state.is_started:
yield
else:
do_router = self.router.finalized
do_signal_router = self.signal_router.finalized
if do_router:
self.router.reset()
if do_signal_router:
self.signal_router.reset()
yield
if do_signal_router:
self.signalize(self.config.TOUCHUP)
if do_router:
self.finalize()
def finalize(self):
try:
self.router.finalize()
@@ -1579,20 +1548,17 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
self.signalize(self.config.TOUCHUP)
self.finalize()
route_names = [route.extra.ident for route in self.router.routes]
route_names = [route.name for route in self.router.routes]
duplicates = {
name for name in route_names if route_names.count(name) > 1
}
if duplicates:
names = ", ".join(duplicates)
message = (
f"Duplicate route names detected: {names}. You should rename "
"one or more of them explicitly by using the `name` param, "
"or changing the implicit name derived from the class and "
"function name. For more details, please see "
"https://sanic.dev/en/guide/release-notes/v23.3.html#duplicated-route-names-are-no-longer-allowed" # noqa
deprecation(
f"Duplicate route names detected: {names}. In the future, "
"Sanic will enforce uniqueness in route naming.",
23.3,
)
raise ServerError(message)
Sanic._check_uvloop_conflict()

View File

@@ -40,8 +40,6 @@ FULL_COLOR_LOGO = """
""" # noqa
SVG_LOGO_SIMPLE = """<svg id=logo-simple viewBox="0 0 964 279"><desc>Sanic</desc><path d="M107 222c9-2 10-20 1-22s-20-2-30-2-17 7-16 14 6 10 15 10h30zm115-1c16-2 30-11 35-23s6-24 2-33-6-14-15-20-24-11-38-10c-7 3-10 13-5 19s17-1 24 4 15 14 13 24-5 15-14 18-50 0-74 0h-17c-6 4-10 15-4 20s16 2 23 3zM251 83q9-1 9-7 0-15-10-16h-13c-10 6-10 20 0 22zM147 60c-4 0-10 3-11 11s5 13 10 12 42 0 67 0c5-3 7-10 6-15s-4-8-9-8zm-33 1c-8 0-16 0-24 3s-20 10-25 20-6 24-4 36 15 22 26 27 78 8 94 3c4-4 4-12 0-18s-69 8-93-10c-8-7-9-23 0-30s12-10 20-10 12 2 16-3 1-15-5-18z" fill="#ff0d68"/><path d="M676 74c0-14-18-9-20 0s0 30 0 39 20 9 20 2zm-297-10c-12 2-15 12-23 23l-41 58H340l22-30c8-12 23-13 30-4s20 24 24 38-10 10-17 10l-68 2q-17 1-48 30c-7 6-10 20 0 24s15-8 20-13 20 -20 58-21h50 c20 2 33 9 52 30 8 10 24-4 16-13L384 65q-3-2-5-1zm131 0c-10 1-12 12-11 20v96c1 10-3 23 5 32s20-5 17-15c0-23-3-46 2-67 5-12 22-14 32-5l103 87c7 5 19 1 18-9v-64c-3-10-20-9-21 2s-20 22-30 13l-97-80c-5-4-10-10-18-10zM701 76v128c2 10 15 12 20 4s0-102 0-124s-20-18-20-7z M850 63c-35 0-69-2-86 15s-20 60-13 66 13 8 16 0 1-10 1-27 12-26 20-32 66-5 85-5 31 4 31-10-18-7-54-7M764 159c-6-2-15-2-16 12s19 37 33 43 23 8 25-4-4-11-11-14q-9-3-22-18c-4-7-3-16-10-19zM828 196c-4 0-8 1-10 5s-4 12 0 15 8 2 12 2h60c5 0 10-2 12-6 3-7-1-16-8-16z" fill="#1f1f1f"/></svg>""" # noqa
ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")

View File

@@ -3,9 +3,10 @@ from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Optional
from urllib.parse import quote
from sanic.compat import Header
from sanic.exceptions import BadRequest, ServerError
from sanic.exceptions import ServerError
from sanic.helpers import Default
from sanic.http import Stage
from sanic.log import error_logger, logger
@@ -21,15 +22,13 @@ if TYPE_CHECKING:
class Lifespan:
def __init__(
self, sanic_app, scope: ASGIScope, receive: ASGIReceive, send: ASGISend
) -> None:
self.sanic_app = sanic_app
self.scope = scope
self.receive = receive
self.send = send
def __init__(self, asgi_app: ASGIApp) -> None:
self.asgi_app = asgi_app
if "server.init.before" in self.sanic_app.signal_router.name_index:
if (
"server.init.before"
in self.asgi_app.sanic_app.signal_router.name_index
):
logger.debug(
'You have set a listener for "before_server_start" '
"in ASGI mode. "
@@ -37,7 +36,10 @@ class Lifespan:
"the ASGI server is started.",
extra={"verbosity": 1},
)
if "server.shutdown.after" in self.sanic_app.signal_router.name_index:
if (
"server.shutdown.after"
in self.asgi_app.sanic_app.signal_router.name_index
):
logger.debug(
'You have set a listener for "after_server_stop" '
"in ASGI mode. "
@@ -55,11 +57,11 @@ class Lifespan:
in sequence since the ASGI lifespan protocol only supports a single
startup event.
"""
await self.sanic_app._startup()
await self.sanic_app._server_event("init", "before")
await self.sanic_app._server_event("init", "after")
await self.asgi_app.sanic_app._startup()
await self.asgi_app.sanic_app._server_event("init", "before")
await self.asgi_app.sanic_app._server_event("init", "after")
if not isinstance(self.sanic_app.config.USE_UVLOOP, 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."
@@ -75,33 +77,35 @@ class Lifespan:
in sequence since the ASGI lifespan protocol only supports a single
shutdown event.
"""
await self.sanic_app._server_event("shutdown", "before")
await self.sanic_app._server_event("shutdown", "after")
await self.asgi_app.sanic_app._server_event("shutdown", "before")
await self.asgi_app.sanic_app._server_event("shutdown", "after")
async def __call__(self) -> None:
while True:
message = await self.receive()
if message["type"] == "lifespan.startup":
try:
await self.startup()
except Exception as e:
error_logger.exception(e)
await self.send(
{"type": "lifespan.startup.failed", "message": str(e)}
)
else:
await self.send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
try:
await self.shutdown()
except Exception as e:
error_logger.exception(e)
await self.send(
{"type": "lifespan.shutdown.failed", "message": str(e)}
)
else:
await self.send({"type": "lifespan.shutdown.complete"})
return
async def __call__(
self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend
) -> None:
message = await receive()
if message["type"] == "lifespan.startup":
try:
await self.startup()
except Exception as e:
error_logger.exception(e)
await send(
{"type": "lifespan.startup.failed", "message": str(e)}
)
else:
await send({"type": "lifespan.startup.complete"})
message = await receive()
if message["type"] == "lifespan.shutdown":
try:
await self.shutdown()
except Exception as e:
error_logger.exception(e)
await send(
{"type": "lifespan.shutdown.failed", "message": str(e)}
)
else:
await send({"type": "lifespan.shutdown.complete"})
class ASGIApp:
@@ -113,78 +117,73 @@ class ASGIApp:
stage: Stage
response: Optional[BaseHTTPResponse]
def __init__(self) -> None:
self.ws = None
@classmethod
async def create(
cls,
sanic_app: Sanic,
scope: ASGIScope,
receive: ASGIReceive,
send: ASGISend,
) -> ASGIApp:
cls, sanic_app, scope: ASGIScope, receive: ASGIReceive, send: ASGISend
) -> "ASGIApp":
instance = cls()
instance.ws = None
instance.sanic_app = sanic_app
instance.transport = MockTransport(scope, receive, send)
instance.transport.loop = sanic_app.loop
instance.stage = Stage.IDLE
instance.response = None
instance.sanic_app.state.is_started = True
setattr(instance.transport, "add_task", sanic_app.loop.create_task)
try:
headers = Header(
[
(
key.decode("ASCII"),
value.decode(errors="surrogateescape"),
)
for key, value in scope.get("headers", [])
]
)
except UnicodeDecodeError:
raise BadRequest(
"Header names can only contain US-ASCII characters"
)
headers = Header(
[
(key.decode("latin-1"), value.decode("latin-1"))
for key, value in scope.get("headers", [])
]
)
instance.lifespan = Lifespan(instance)
if scope["type"] == "http":
version = scope["http_version"]
method = scope["method"]
elif scope["type"] == "websocket":
version = "1.1"
method = "GET"
instance.ws = instance.transport.create_websocket_connection(
send, receive
)
if scope["type"] == "lifespan":
await instance.lifespan(scope, receive, send)
else:
raise ServerError("Received unknown ASGI scope")
path = (
scope["path"][1:]
if scope["path"].startswith("/")
else scope["path"]
)
url = "/".join([scope.get("root_path", ""), quote(path)])
url_bytes = url.encode("latin-1")
url_bytes += b"?" + scope["query_string"]
url_bytes, query = scope["raw_path"], scope["query_string"]
if query:
# httpx ASGI client sends query string as part of raw_path
url_bytes = url_bytes.split(b"?", 1)[0]
# All servers send them separately
url_bytes = b"%b?%b" % (url_bytes, query)
if scope["type"] == "http":
version = scope["http_version"]
method = scope["method"]
elif scope["type"] == "websocket":
version = "1.1"
method = "GET"
request_class = sanic_app.request_class or Request
instance.request = request_class(
url_bytes,
headers,
version,
method,
instance.transport,
sanic_app,
)
instance.request.stream = instance # type: ignore
instance.request_body = True
instance.request.conn_info = ConnInfo(instance.transport)
instance.ws = instance.transport.create_websocket_connection(
send, receive
)
else:
raise ServerError("Received unknown ASGI scope")
await instance.sanic_app.dispatch(
"http.lifecycle.request",
inline=True,
context={"request": instance.request},
fail_not_found=False,
)
request_class = sanic_app.request_class or Request
instance.request = request_class(
url_bytes,
headers,
version,
method,
instance.transport,
sanic_app,
)
instance.request.stream = instance
instance.request_body = True
instance.request.conn_info = ConnInfo(instance.transport)
await sanic_app.dispatch(
"http.lifecycle.request",
inline=True,
context={"request": instance.request},
fail_not_found=False,
)
return instance

View File

@@ -9,7 +9,6 @@ from sanic.mixins.listeners import ListenerMixin
from sanic.mixins.middleware import MiddlewareMixin
from sanic.mixins.routes import RouteMixin
from sanic.mixins.signals import SignalMixin
from sanic.mixins.static import StaticMixin
VALID_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$")
@@ -17,7 +16,6 @@ VALID_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$")
class BaseSanic(
RouteMixin,
StaticMixin,
MiddlewareMixin,
ListenerMixin,
ExceptionMixin,

View File

@@ -93,7 +93,6 @@ class Blueprint(BaseSanic):
"_future_listeners",
"_future_exceptions",
"_future_signals",
"copied_from",
"ctx",
"exceptions",
"host",
@@ -119,7 +118,6 @@ class Blueprint(BaseSanic):
):
super().__init__(name=name)
self.reset()
self.copied_from = ""
self.ctx = SimpleNamespace()
self.host = host
self.strict_slashes = strict_slashes
@@ -215,7 +213,6 @@ class Blueprint(BaseSanic):
self.reset()
new_bp = deepcopy(self)
new_bp.name = name
new_bp.copied_from = self.name
if not isinstance(url_prefix, Default):
new_bp.url_prefix = url_prefix
@@ -307,6 +304,9 @@ class Blueprint(BaseSanic):
# Routes
for future in self._future_routes:
# attach the blueprint name to the handler so that it can be
# prefixed properly in the router
future.handler.__blueprintname__ = self.name
# Prepend the blueprint URI prefix if available
uri = self._setup_uri(future.uri, url_prefix)
@@ -355,16 +355,6 @@ class Blueprint(BaseSanic):
registered.add(apply_route)
route = app._apply_route(apply_route)
# If it is a copied BP, then make sure all of the names of routes
# matchup with the new BP name
if self.copied_from:
for r in route:
r.name = r.name.replace(self.copied_from, self.name)
r.extra.ident = r.extra.ident.replace(
self.copied_from, self.name
)
operation = (
routes.extend if isinstance(route, list) else routes.append
)

View File

@@ -1,3 +1,4 @@
import logging
import os
import shutil
import sys
@@ -5,7 +6,7 @@ import sys
from argparse import Namespace
from functools import partial
from textwrap import indent
from typing import List, Union
from typing import List, Union, cast
from sanic.app import Sanic
from sanic.application.logo import get_logo
@@ -13,7 +14,7 @@ from sanic.cli.arguments import Group
from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter
from sanic.cli.inspector import make_inspector_parser
from sanic.cli.inspector_client import InspectorClient
from sanic.log import error_logger
from sanic.log import Colors, error_logger
from sanic.worker.loader import AppLoader
@@ -23,22 +24,17 @@ class SanicCLI:
{get_logo(True)}
To start running a Sanic application, provide a path to the module, where
app is a Sanic() instance in the global scope:
app is a Sanic() instance:
$ sanic path.to.server:app
If the Sanic instance variable is called 'app', you can leave off the last
part, and only provide a path to the module where the instance is:
$ sanic path.to.server
Or, a path to a callable that returns a Sanic() instance:
$ sanic path.to.factory:create_app
$ sanic path.to.factory:create_app --factory
Or, a path to a directory to run as a simple HTTP server:
$ sanic ./path/to/static
$ sanic ./path/to/static --simple
""",
prefix=" ",
)
@@ -99,9 +95,13 @@ Or, a path to a directory to run as a simple HTTP server:
self.args = self.parser.parse_args(args=parse_args)
self._precheck()
app_loader = AppLoader(
self.args.target, self.args.factory, self.args.simple, self.args
self.args.module, self.args.factory, self.args.simple, self.args
)
if self.args.inspect or self.args.inspect_raw or self.args.trigger:
self._inspector_legacy(app_loader)
return
try:
app = self._get_app(app_loader)
kwargs = self._build_run_kwargs()
@@ -112,10 +112,38 @@ Or, a path to a directory to run as a simple HTTP server:
app.prepare(**kwargs, version=http_version)
if self.args.single:
serve = Sanic.serve_single
elif self.args.legacy:
serve = Sanic.serve_legacy
else:
serve = partial(Sanic.serve, app_loader=app_loader)
serve(app)
def _inspector_legacy(self, app_loader: AppLoader):
host = port = None
module = cast(str, self.args.module)
if ":" in module:
maybe_host, maybe_port = module.rsplit(":", 1)
if maybe_port.isnumeric():
host, port = maybe_host, int(maybe_port)
if not host:
app = self._get_app(app_loader)
host, port = app.config.INSPECTOR_HOST, app.config.INSPECTOR_PORT
action = self.args.trigger or "info"
InspectorClient(
str(host), int(port or 6457), False, self.args.inspect_raw, ""
).do(action)
sys.stdout.write(
f"\n{Colors.BOLD}{Colors.YELLOW}WARNING:{Colors.END} "
"You are using the legacy CLI command that will be removed in "
f"{Colors.RED}v23.3{Colors.END}. See "
"https://sanic.dev/en/guide/release-notes/v22.12.html"
"#deprecations-and-removals or checkout the new "
"style commands:\n\n\t"
f"{Colors.YELLOW}sanic inspect --help{Colors.END}\n"
)
def _inspector(self):
args = sys.argv[2:]
self.args, unknown = self.parser.parse_known_args(args=args)
@@ -169,6 +197,8 @@ Or, a path to a directory to run as a simple HTTP server:
)
error_logger.error(message)
sys.exit(1)
if self.args.inspect or self.args.inspect_raw:
logging.disable(logging.CRITICAL)
def _get_app(self, app_loader: AppLoader):
try:
@@ -216,6 +246,7 @@ Or, a path to a directory to run as a simple HTTP server:
"workers": self.args.workers,
"auto_tls": self.args.auto_tls,
"single_process": self.args.single,
"legacy": self.args.legacy,
}
for maybe_arg in ("auto_reload", "dev"):

View File

@@ -57,15 +57,11 @@ class GeneralGroup(Group):
)
self.container.add_argument(
"target",
"module",
help=(
"Path to your Sanic app instance.\n"
"\tExample: path.to.server:app\n"
"If running a Simple Server, path to directory to serve.\n"
"\tExample: ./\n"
"Additionally, this can be a path to a factory function\n"
"that returns a Sanic app instance.\n"
"\tExample: path.to.server:create_app\n"
"Path to your Sanic app. Example: path.to.server:app\n"
"If running a Simple Server, path to directory to serve. "
"Example: ./\n"
),
)
@@ -93,6 +89,32 @@ class ApplicationGroup(Group):
"a directory\n(module arg should be a path)"
),
)
group.add_argument(
"--inspect",
dest="inspect",
action="store_true",
help=("Inspect the state of a running instance, human readable"),
)
group.add_argument(
"--inspect-raw",
dest="inspect_raw",
action="store_true",
help=("Inspect the state of a running instance, JSON output"),
)
group.add_argument(
"--trigger-reload",
dest="trigger",
action="store_const",
const="reload",
help=("Trigger worker processes to reload"),
)
group.add_argument(
"--trigger-shutdown",
dest="trigger",
action="store_const",
const="shutdown",
help=("Trigger all processes to shutdown"),
)
class HTTPVersionGroup(Group):
@@ -221,6 +243,11 @@ class WorkerGroup(Group):
action="store_true",
help="Do not use multiprocessing, run server in a single process",
)
self.container.add_argument(
"--legacy",
action="store_true",
help="Use the legacy server manager",
)
self.add_bool_arguments(
"--access-logs",
dest="access_log",

View File

@@ -88,12 +88,6 @@ class Header(CIMultiDict):
very similar to a regular dictionary.
"""
def __getattr__(self, key: str) -> str:
if key.startswith("_"):
return self.__getattribute__(key)
key = key.rstrip("_").replace("_", "-")
return ",".join(self.getall(key, default=[]))
def get_all(self, key: str):
"""
Convenience method mapped to ``getall()``.

156
sanic/cookies.py Normal file
View File

@@ -0,0 +1,156 @@
import re
import string
from datetime import datetime
from typing import Dict
DEFAULT_MAX_AGE = 0
# ------------------------------------------------------------ #
# SimpleCookie
# ------------------------------------------------------------ #
# Straight up copied this section of dark magic from SimpleCookie
_LegalChars = string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~:"
_UnescapedChars = _LegalChars + " ()/<=>?@[]{}"
_Translator = {
n: "\\%03o" % n for n in set(range(256)) - set(map(ord, _UnescapedChars))
}
_Translator.update({ord('"'): '\\"', ord("\\"): "\\\\"})
def _quote(str):
r"""Quote a string for use in a cookie header.
If the string does not need to be double-quoted, then just return the
string. Otherwise, surround the string in doublequotes and quote
(with a \) special characters.
"""
if str is None or _is_legal_key(str):
return str
else:
return '"' + str.translate(_Translator) + '"'
_is_legal_key = re.compile("[%s]+" % re.escape(_LegalChars)).fullmatch
# ------------------------------------------------------------ #
# Custom SimpleCookie
# ------------------------------------------------------------ #
class CookieJar(dict):
"""
CookieJar dynamically writes headers as cookies are added and removed
It gets around the limitation of one header per name by using the
MultiHeader class to provide a unique key that encodes to Set-Cookie.
"""
def __init__(self, headers):
super().__init__()
self.headers: Dict[str, str] = headers
self.cookie_headers: Dict[str, str] = {}
self.header_key: str = "Set-Cookie"
def __setitem__(self, key, value):
# If this cookie doesn't exist, add it to the header keys
if not self.cookie_headers.get(key):
cookie = Cookie(key, value)
cookie["path"] = "/"
self.cookie_headers[key] = self.header_key
self.headers.add(self.header_key, cookie)
return super().__setitem__(key, cookie)
else:
self[key].value = value
def __delitem__(self, key):
if key not in self.cookie_headers:
self[key] = ""
self[key]["max-age"] = 0
else:
cookie_header = self.cookie_headers[key]
# remove it from header
cookies = self.headers.popall(cookie_header)
for cookie in cookies:
if cookie.key != key:
self.headers.add(cookie_header, cookie)
del self.cookie_headers[key]
return super().__delitem__(key)
class Cookie(dict):
"""A stripped down version of Morsel from SimpleCookie #gottagofast"""
_keys = {
"expires": "expires",
"path": "Path",
"comment": "Comment",
"domain": "Domain",
"max-age": "Max-Age",
"secure": "Secure",
"httponly": "HttpOnly",
"version": "Version",
"samesite": "SameSite",
}
_flags = {"secure", "httponly"}
def __init__(self, key, value):
if key in self._keys:
raise KeyError("Cookie name is a reserved word")
if not _is_legal_key(key):
raise KeyError("Cookie key contains illegal characters")
self.key = key
self.value = value
super().__init__()
def __setitem__(self, key, value):
if key not in self._keys:
raise KeyError("Unknown cookie property")
if value is not False:
if key.lower() == "max-age":
if not str(value).isdigit():
raise ValueError("Cookie max-age must be an integer")
elif key.lower() == "expires":
if not isinstance(value, datetime):
raise TypeError(
"Cookie 'expires' property must be a datetime"
)
return super().__setitem__(key, value)
def encode(self, encoding):
"""
Encode the cookie content in a specific type of encoding instructed
by the developer. Leverages the :func:`str.encode` method provided
by python.
This method can be used to encode and embed ``utf-8`` content into
the cookies.
:param encoding: Encoding to be used with the cookie
:return: Cookie encoded in a codec of choosing.
:except: UnicodeEncodeError
"""
return str(self).encode(encoding)
def __str__(self):
"""Format as a Set-Cookie header value."""
output = ["%s=%s" % (self.key, _quote(self.value))]
for key, value in self.items():
if key == "max-age":
try:
output.append("%s=%d" % (self._keys[key], value))
except TypeError:
output.append("%s=%s" % (self._keys[key], value))
elif key == "expires":
output.append(
"%s=%s"
% (self._keys[key], value.strftime("%a, %d-%b-%Y %T GMT"))
)
elif key in self._flags and self[key]:
output.append(self._keys[key])
else:
output.append("%s=%s" % (self._keys[key], value))
return "; ".join(output)

View File

@@ -1,4 +0,0 @@
from .response import Cookie, CookieJar
__all__ = ("Cookie", "CookieJar")

View File

@@ -1,119 +0,0 @@
import re
from typing import Any, Dict, List, Optional
from sanic.cookies.response import Cookie
from sanic.log import deprecation
from sanic.request.parameters import RequestParameters
COOKIE_NAME_RESERVED_CHARS = re.compile(
'[\x00-\x1F\x7F-\xFF()<>@,;:\\\\"/[\\]?={} \x09]'
)
OCTAL_PATTERN = re.compile(r"\\[0-3][0-7][0-7]")
QUOTE_PATTERN = re.compile(r"[\\].")
def _unquote(str): # no cov
if str is None or len(str) < 2:
return str
if str[0] != '"' or str[-1] != '"':
return str
str = str[1:-1]
i = 0
n = len(str)
res = []
while 0 <= i < n:
o_match = OCTAL_PATTERN.search(str, i)
q_match = QUOTE_PATTERN.search(str, i)
if not o_match and not q_match:
res.append(str[i:])
break
# else:
j = k = -1
if o_match:
j = o_match.start(0)
if q_match:
k = q_match.start(0)
if q_match and (not o_match or k < j):
res.append(str[i:k])
res.append(str[k + 1])
i = k + 2
else:
res.append(str[i:j])
res.append(chr(int(str[j + 1 : j + 4], 8))) # noqa: E203
i = j + 4
return "".join(res)
def parse_cookie(raw: str):
cookies: Dict[str, List] = {}
for token in raw.split(";"):
name, __, value = token.partition("=")
name = name.strip()
value = value.strip()
if not name:
continue
if COOKIE_NAME_RESERVED_CHARS.search(name): # no cov
continue
if len(value) > 2 and value[0] == '"' and value[-1] == '"': # no cov
value = _unquote(value)
if name in cookies:
cookies[name].append(value)
else:
cookies[name] = [value]
return cookies
class CookieRequestParameters(RequestParameters):
def __getitem__(self, key: str) -> Optional[str]:
deprecation(
f"You are accessing cookie key '{key}', which is currently in "
"compat mode returning a single cookie value. Starting in v24.3 "
"accessing a cookie value like this will return a list of values. "
"To avoid this behavior and continue accessing a single value, "
f"please upgrade from request.cookies['{key}'] to "
f"request.cookies.get('{key}'). See more details: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#request-cookies", # noqa
24.3,
)
try:
value = self._get_prefixed_cookie(key)
except KeyError:
value = super().__getitem__(key)
return value[0]
def __getattr__(self, key: str) -> str:
if key.startswith("_"):
return self.__getattribute__(key)
key = key.rstrip("_").replace("_", "-")
return str(self.get(key, ""))
def get(self, name: str, default: Optional[Any] = None) -> Optional[Any]:
try:
return self._get_prefixed_cookie(name)[0]
except KeyError:
return super().get(name, default)
def getlist(
self, name: str, default: Optional[Any] = None
) -> Optional[Any]:
try:
return self._get_prefixed_cookie(name)
except KeyError:
return super().getlist(name, default)
def _get_prefixed_cookie(self, name: str) -> Any:
getitem = super().__getitem__
try:
return getitem(f"{Cookie.HOST_PREFIX}{name}")
except KeyError:
return getitem(f"{Cookie.SECURE_PREFIX}{name}")

View File

@@ -1,617 +0,0 @@
from __future__ import annotations
import re
import string
import sys
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from sanic.exceptions import ServerError
from sanic.log import deprecation
if TYPE_CHECKING:
from sanic.compat import Header
if sys.version_info < (3, 8): # no cov
SameSite = str
else: # no cov
from typing import Literal
SameSite = Union[
Literal["Strict"],
Literal["Lax"],
Literal["None"],
Literal["strict"],
Literal["lax"],
Literal["none"],
]
DEFAULT_MAX_AGE = 0
SAMESITE_VALUES = ("strict", "lax", "none")
LEGAL_CHARS = string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~:"
UNESCAPED_CHARS = LEGAL_CHARS + " ()/<=>?@[]{}"
TRANSLATOR = {ch: f"\\{ch:03o}" for ch in bytes(range(32)) + b'";\\\x7F'}
def _quote(str): # no cov
r"""Quote a string for use in a cookie header.
If the string does not need to be double-quoted, then just return the
string. Otherwise, surround the string in doublequotes and quote
(with a \) special characters.
"""
if str is None or _is_legal_key(str):
return str
else:
return f'"{str.translate(TRANSLATOR)}"'
_is_legal_key = re.compile("[%s]+" % re.escape(LEGAL_CHARS)).fullmatch
# In v24.3, we should remove this as being a subclass of dict
class CookieJar(dict):
"""
CookieJar dynamically writes headers as cookies are added and removed
It gets around the limitation of one header per name by using the
MultiHeader class to provide a unique key that encodes to Set-Cookie.
"""
HEADER_KEY = "Set-Cookie"
def __init__(self, headers: Header):
super().__init__()
self.headers = headers
def __setitem__(self, key, value):
# If this cookie doesn't exist, add it to the header keys
deprecation(
"Setting cookie values using the dict pattern has been "
"deprecated. You should instead use the cookies.add_cookie "
"method. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
0,
)
if key not in self:
self.add_cookie(key, value, secure=False, samesite=None)
else:
self[key].value = value
def __delitem__(self, key):
deprecation(
"Deleting cookie values using the dict pattern has been "
"deprecated. You should instead use the cookies.delete_cookie "
"method. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
0,
)
if key in self:
super().__delitem__(key)
self.delete_cookie(key)
def __len__(self): # no cov
return len(self.cookies)
def __getitem__(self, key: str) -> Cookie:
deprecation(
"Accessing cookies from the CookieJar by dict key is deprecated. "
"You should instead use the cookies.get_cookie method. "
"To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
0,
)
return super().__getitem__(key)
def __iter__(self): # no cov
deprecation(
"Iterating over the CookieJar has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().__iter__()
def keys(self): # no cov
deprecation(
"Accessing CookieJar.keys() has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().keys()
def values(self): # no cov
deprecation(
"Accessing CookieJar.values() has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().values()
def items(self): # no cov
deprecation(
"Accessing CookieJar.items() has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().items()
def get(self, *args, **kwargs): # no cov
deprecation(
"Accessing cookies from the CookieJar using get is deprecated "
"and will be removed in v24.3. You should instead use the "
"cookies.get_cookie method. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().get(*args, **kwargs)
def pop(self, key, *args, **kwargs): # no cov
deprecation(
"Using CookieJar.pop() has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
self.delete(key)
return super().pop(key, *args, **kwargs)
@property
def header_key(self): # no cov
deprecation(
"The CookieJar.header_key property has been deprecated and will "
"be removed in version 24.3. Use CookieJar.HEADER_KEY. ",
24.3,
)
return CookieJar.HEADER_KEY
@property
def cookie_headers(self) -> Dict[str, str]: # no cov
deprecation(
"The CookieJar.coookie_headers property has been deprecated "
"and will be removed in version 24.3. If you need to check if a "
"particular cookie key has been set, use CookieJar.has_cookie.",
24.3,
)
return {key: self.header_key for key in self}
@property
def cookies(self) -> List[Cookie]:
return self.headers.getall(self.HEADER_KEY)
def get_cookie(
self,
key: str,
path: str = "/",
domain: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> Optional[Cookie]:
for cookie in self.cookies:
if (
cookie.key == Cookie.make_key(key, host_prefix, secure_prefix)
and cookie.path == path
and cookie.domain == domain
):
return cookie
return None
def has_cookie(
self,
key: str,
path: str = "/",
domain: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> bool:
for cookie in self.cookies:
if (
cookie.key == Cookie.make_key(key, host_prefix, secure_prefix)
and cookie.path == path
and cookie.domain == domain
):
return True
return False
def add_cookie(
self,
key: str,
value: str,
*,
path: str = "/",
domain: Optional[str] = None,
secure: bool = True,
max_age: Optional[int] = None,
expires: Optional[datetime] = None,
httponly: bool = False,
samesite: Optional[SameSite] = "Lax",
partitioned: bool = False,
comment: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> Cookie:
"""
Add a cookie to the CookieJar
:param key: Key of the cookie
:type key: str
:param value: Value of the cookie
:type value: str
:param path: Path of the cookie, defaults to None
:type path: Optional[str], optional
:param domain: Domain of the cookie, defaults to None
:type domain: Optional[str], optional
:param secure: Whether to set it as a secure cookie, defaults to True
:type secure: bool
:param max_age: Max age of the cookie in seconds; if set to 0 a
browser should delete it, defaults to None
:type max_age: Optional[int], optional
:param expires: When the cookie expires; if set to None browsers
should set it as a session cookie, defaults to None
:type expires: Optional[datetime], optional
:param httponly: Whether to set it as HTTP only, defaults to False
:type httponly: bool
:param samesite: How to set the samesite property, should be
strict, lax or none (case insensitive), defaults to Lax
:type samesite: Optional[SameSite], optional
:param partitioned: Whether to set it as partitioned, defaults to False
:type partitioned: bool
:param comment: A cookie comment, defaults to None
:type comment: Optional[str], optional
:param host_prefix: Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True,
defaults to False
:type host_prefix: bool
:param secure_prefix: Whether to add __Secure- as a prefix to the key.
This requires that secure=True, defaults to False
:type secure_prefix: bool
:return: The instance of the created cookie
:rtype: Cookie
"""
cookie = Cookie(
key,
value,
path=path,
expires=expires,
comment=comment,
domain=domain,
max_age=max_age,
secure=secure,
httponly=httponly,
samesite=samesite,
partitioned=partitioned,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
self.headers.add(self.HEADER_KEY, cookie)
# This should be removed in v24.3
super().__setitem__(key, cookie)
return cookie
def delete_cookie(
self,
key: str,
*,
path: str = "/",
domain: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> None:
"""
Delete a cookie
This will effectively set it as Max-Age: 0, which a browser should
interpret it to mean: "delete the cookie".
Since it is a browser/client implementation, your results may vary
depending upon which client is being used.
:param key: The key to be deleted
:type key: str
:param path: Path of the cookie, defaults to None
:type path: Optional[str], optional
:param domain: Domain of the cookie, defaults to None
:type domain: Optional[str], optional
:param host_prefix: Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True,
defaults to False
:type host_prefix: bool
:param secure_prefix: Whether to add __Secure- as a prefix to the key.
This requires that secure=True, defaults to False
:type secure_prefix: bool
"""
# remove it from header
cookies: List[Cookie] = self.headers.popall(self.HEADER_KEY, [])
for cookie in cookies:
if (
cookie.key != Cookie.make_key(key, host_prefix, secure_prefix)
or cookie.path != path
or cookie.domain != domain
):
self.headers.add(self.HEADER_KEY, cookie)
# This should be removed in v24.3
try:
super().__delitem__(key)
except KeyError:
...
self.add_cookie(
key=key,
value="",
path=path,
domain=domain,
max_age=0,
samesite=None,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
# In v24.3, we should remove this as being a subclass of dict
# Instead, it should be an object with __slots__
# All of the current property accessors should be removed in favor
# of actual slotted properties.
class Cookie(dict):
"""A stripped down version of Morsel from SimpleCookie"""
HOST_PREFIX = "__Host-"
SECURE_PREFIX = "__Secure-"
_keys = {
"path": "Path",
"comment": "Comment",
"domain": "Domain",
"max-age": "Max-Age",
"expires": "expires",
"samesite": "SameSite",
"version": "Version",
"secure": "Secure",
"httponly": "HttpOnly",
"partitioned": "Partitioned",
}
_flags = {"secure", "httponly", "partitioned"}
def __init__(
self,
key: str,
value: str,
*,
path: str = "/",
domain: Optional[str] = None,
secure: bool = True,
max_age: Optional[int] = None,
expires: Optional[datetime] = None,
httponly: bool = False,
samesite: Optional[SameSite] = "Lax",
partitioned: bool = False,
comment: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
):
if key in self._keys:
raise KeyError("Cookie name is a reserved word")
if not _is_legal_key(key):
raise KeyError("Cookie key contains illegal characters")
if host_prefix:
if not secure:
raise ServerError(
"Cannot set host_prefix on a cookie without secure=True"
)
if path != "/":
raise ServerError(
"Cannot set host_prefix on a cookie unless path='/'"
)
if domain:
raise ServerError(
"Cannot set host_prefix on a cookie with a defined domain"
)
elif secure_prefix and not secure:
raise ServerError(
"Cannot set secure_prefix on a cookie without secure=True"
)
if partitioned and not host_prefix:
# This is technically possible, but it is not advisable so we will
# take a stand and say "don't shoot yourself in the foot"
raise ServerError(
"Cannot create a partitioned cookie without "
"also setting host_prefix=True"
)
self.key = self.make_key(key, host_prefix, secure_prefix)
self.value = value
super().__init__()
# This is a temporary solution while this object is a dict. We update
# all of the values in bulk, except for the values that have
# key-specific validation in _set_value
self.update(
{
"path": path,
"comment": comment,
"domain": domain,
"secure": secure,
"httponly": httponly,
"partitioned": partitioned,
"expires": None,
"max-age": None,
"samesite": None,
}
)
if expires is not None:
self._set_value("expires", expires)
if max_age is not None:
self._set_value("max-age", max_age)
if samesite is not None:
self._set_value("samesite", samesite)
def __setitem__(self, key, value):
deprecation(
"Setting values on a Cookie object as a dict has been deprecated. "
"This feature will be removed in v24.3. You should instead set "
f"values on cookies as object properties: cookie.{key}=... ",
24.3,
)
self._set_value(key, value)
# This is a temporary method for backwards compat and should be removed
# in v24.3 when this is no longer a dict
def _set_value(self, key: str, value: Any) -> None:
if key not in self._keys:
raise KeyError("Unknown cookie property: %s=%s" % (key, value))
if value is not None:
if key.lower() == "max-age" and not str(value).isdigit():
raise ValueError("Cookie max-age must be an integer")
elif key.lower() == "expires" and not isinstance(value, datetime):
raise TypeError("Cookie 'expires' property must be a datetime")
elif key.lower() == "samesite":
if value.lower() not in SAMESITE_VALUES:
raise TypeError(
"Cookie 'samesite' property must "
f"be one of: {','.join(SAMESITE_VALUES)}"
)
value = value.title()
super().__setitem__(key, value)
def encode(self, encoding):
"""
Encode the cookie content in a specific type of encoding instructed
by the developer. Leverages the :func:`str.encode` method provided
by python.
This method can be used to encode and embed ``utf-8`` content into
the cookies.
:param encoding: Encoding to be used with the cookie
:return: Cookie encoded in a codec of choosing.
:except: UnicodeEncodeError
"""
deprecation(
"Direct encoding of a Cookie object has been deprecated and will "
"be removed in v24.3.",
24.3,
)
return str(self).encode(encoding)
def __str__(self):
"""Format as a Set-Cookie header value."""
output = ["%s=%s" % (self.key, _quote(self.value))]
key_index = list(self._keys)
for key, value in sorted(
self.items(), key=lambda x: key_index.index(x[0])
):
if value is not None and value is not False:
if key == "max-age":
try:
output.append("%s=%d" % (self._keys[key], value))
except TypeError:
output.append("%s=%s" % (self._keys[key], value))
elif key == "expires":
output.append(
"%s=%s"
% (
self._keys[key],
value.strftime("%a, %d-%b-%Y %T GMT"),
)
)
elif key in self._flags:
output.append(self._keys[key])
else:
output.append("%s=%s" % (self._keys[key], value))
return "; ".join(output)
@property
def path(self) -> str: # no cov
return self["path"]
@path.setter
def path(self, value: str) -> None: # no cov
self._set_value("path", value)
@property
def expires(self) -> Optional[datetime]: # no cov
return self.get("expires")
@expires.setter
def expires(self, value: datetime) -> None: # no cov
self._set_value("expires", value)
@property
def comment(self) -> Optional[str]: # no cov
return self.get("comment")
@comment.setter
def comment(self, value: str) -> None: # no cov
self._set_value("comment", value)
@property
def domain(self) -> Optional[str]: # no cov
return self.get("domain")
@domain.setter
def domain(self, value: str) -> None: # no cov
self._set_value("domain", value)
@property
def max_age(self) -> Optional[int]: # no cov
return self.get("max-age")
@max_age.setter
def max_age(self, value: int) -> None: # no cov
self._set_value("max-age", value)
@property
def secure(self) -> bool: # no cov
return self.get("secure", False)
@secure.setter
def secure(self, value: bool) -> None: # no cov
self._set_value("secure", value)
@property
def httponly(self) -> bool: # no cov
return self.get("httponly", False)
@httponly.setter
def httponly(self, value: bool) -> None: # no cov
self._set_value("httponly", value)
@property
def samesite(self) -> Optional[SameSite]: # no cov
return self.get("samesite")
@samesite.setter
def samesite(self, value: SameSite) -> None: # no cov
self._set_value("samesite", value)
@property
def partitioned(self) -> bool: # no cov
return self.get("partitioned", False)
@partitioned.setter
def partitioned(self, value: bool) -> None: # no cov
self._set_value("partitioned", value)
@classmethod
def make_key(
cls, key: str, host_prefix: bool = False, secure_prefix: bool = False
) -> str:
if host_prefix and secure_prefix:
raise ServerError(
"Both host_prefix and secure_prefix were requested. "
"A cookie should have only one prefix."
)
elif host_prefix:
key = cls.HOST_PREFIX + key
elif secure_prefix:
key = cls.SECURE_PREFIX + key
return key

View File

@@ -12,7 +12,6 @@ Setting ``app.config.FALLBACK_ERROR_FORMAT = "auto"`` will enable a switch that
will attempt to provide an appropriate response format based upon the
request type.
"""
from __future__ import annotations
import sys
import typing as t
@@ -22,9 +21,8 @@ from traceback import extract_tb
from sanic.exceptions import BadRequest, SanicException
from sanic.helpers import STATUS_CODES
from sanic.log import deprecation, logger
from sanic.pages.error import ErrorPage
from sanic.response import html, json, text
from sanic.request import Request
from sanic.response import HTTPResponse, html, json, text
dumps: t.Callable[..., str]
@@ -35,15 +33,13 @@ try:
except ImportError: # noqa
from json import dumps
if t.TYPE_CHECKING:
from sanic import HTTPResponse, Request
DEFAULT_FORMAT = "auto"
FALLBACK_TEXT = """\
The application encountered an unexpected error and could not continue.\
"""
FALLBACK_TEXT = (
"The server encountered an internal error and "
"cannot complete your request."
)
FALLBACK_STATUS = 500
JSON = "application/json"
class BaseRenderer:
@@ -117,18 +113,134 @@ class HTMLRenderer(BaseRenderer):
The default fallback type.
"""
TRACEBACK_STYLE = """
html { font-family: sans-serif }
h2 { color: #888; }
.tb-wrapper p, dl, dd { margin: 0 }
.frame-border { margin: 1rem }
.frame-line > *, dt, dd { padding: 0.3rem 0.6rem }
.frame-line, dl { margin-bottom: 0.3rem }
.frame-code, dd { font-size: 16px; padding-left: 4ch }
.tb-wrapper, dl { border: 1px solid #eee }
.tb-header,.obj-header {
background: #eee; padding: 0.3rem; font-weight: bold
}
.frame-descriptor, dt { background: #e2eafb; font-size: 14px }
"""
TRACEBACK_WRAPPER_HTML = (
"<div class=tb-header>{exc_name}: {exc_value}</div>"
"<div class=tb-wrapper>{frame_html}</div>"
)
TRACEBACK_BORDER = (
"<div class=frame-border>"
"The above exception was the direct cause of the following exception:"
"</div>"
)
TRACEBACK_LINE_HTML = (
"<div class=frame-line>"
"<p class=frame-descriptor>"
"File {0.filename}, line <i>{0.lineno}</i>, "
"in <code><b>{0.name}</b></code>"
"<p class=frame-code><code>{0.line}</code>"
"</div>"
)
OBJECT_WRAPPER_HTML = (
"<div class=obj-header>{title}</div>"
"<dl class={obj_type}>{display_html}</dl>"
)
OBJECT_DISPLAY_HTML = "<dt>{key}</dt><dd><code>{value}</code></dd>"
OUTPUT_HTML = (
"<!DOCTYPE html><html lang=en>"
"<meta charset=UTF-8><title>{title}</title>\n"
"<style>{style}</style>\n"
"<h1>{title}</h1><p>{text}\n"
"{body}"
)
def full(self) -> HTTPResponse:
page = ErrorPage(
debug=self.debug,
title=super().title,
text=super().text,
request=self.request,
exc=self.exception,
return html(
self.OUTPUT_HTML.format(
title=self.title,
text=self.text,
style=self.TRACEBACK_STYLE,
body=self._generate_body(full=True),
),
status=self.status,
)
return html(page.render(), status=self.status, headers=self.headers)
def minimal(self) -> HTTPResponse:
return self.full()
return html(
self.OUTPUT_HTML.format(
title=self.title,
text=self.text,
style=self.TRACEBACK_STYLE,
body=self._generate_body(full=False),
),
status=self.status,
headers=self.headers,
)
@property
def text(self):
return escape(super().text)
@property
def title(self):
return escape(f"⚠️ {super().title}")
def _generate_body(self, *, full):
lines = []
if full:
_, exc_value, __ = sys.exc_info()
exceptions = []
while exc_value:
exceptions.append(self._format_exc(exc_value))
exc_value = exc_value.__cause__
traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions))
appname = escape(self.request.app.name)
name = escape(self.exception.__class__.__name__)
value = escape(self.exception)
path = escape(self.request.path)
lines += [
f"<h2>Traceback of {appname} " "(most recent call last):</h2>",
f"{traceback_html}",
"<div class=summary><p>",
f"<b>{name}: {value}</b> "
f"while handling path <code>{path}</code>",
"</div>",
]
for attr, display in (("context", True), ("extra", bool(full))):
info = getattr(self.exception, attr, None)
if info and display:
lines.append(self._generate_object_display(info, attr))
return "\n".join(lines)
def _generate_object_display(
self, obj: t.Dict[str, t.Any], descriptor: str
) -> str:
display = "".join(
self.OBJECT_DISPLAY_HTML.format(key=key, value=value)
for key, value in obj.items()
)
return self.OBJECT_WRAPPER_HTML.format(
title=descriptor.title(),
display_html=display,
obj_type=descriptor.lower(),
)
def _format_exc(self, exc):
frames = extract_tb(exc.__traceback__)
frame_html = "".join(
self.TRACEBACK_LINE_HTML.format(frame) for frame in frames
)
return self.TRACEBACK_WRAPPER_HTML.format(
exc_name=escape(exc.__class__.__name__),
exc_value=escape(exc),
frame_html=frame_html,
)
class TextRenderer(BaseRenderer):
@@ -276,26 +388,32 @@ def escape(text):
return f"{text}".replace("&", "&amp;").replace("<", "&lt;")
MIME_BY_CONFIG = {
"text": "text/plain",
"json": "application/json",
"html": "text/html",
RENDERERS_BY_CONFIG = {
"html": HTMLRenderer,
"json": JSONRenderer,
"text": TextRenderer,
}
CONFIG_BY_MIME = {v: k for k, v in MIME_BY_CONFIG.items()}
RENDERERS_BY_CONTENT_TYPE = {
"text/plain": TextRenderer,
"application/json": JSONRenderer,
"multipart/form-data": HTMLRenderer,
"text/html": HTMLRenderer,
}
CONTENT_TYPE_BY_RENDERERS = {
v: k for k, v in RENDERERS_BY_CONTENT_TYPE.items()
}
# Handler source code is checked for which response types it returns with the
# route error_format="auto" (default) to determine which format to use.
RESPONSE_MAPPING = {
"empty": "html",
"json": "json",
"text": "text",
"raw": "text",
"html": "html",
"JSONResponse": "json",
"file": "html",
"file_stream": "text",
"stream": "text",
"redirect": "html",
"text/plain": "text",
"text/html": "html",
"application/json": "json",
@@ -303,7 +421,7 @@ RESPONSE_MAPPING = {
def check_error_format(format):
if format not in MIME_BY_CONFIG and format != "auto":
if format not in RENDERERS_BY_CONFIG and format != "auto":
raise SanicException(f"Unknown format: {format}")
@@ -318,68 +436,98 @@ def exception_response(
"""
Render a response for the default FALLBACK exception handler.
"""
content_type = None
if not renderer:
mt = guess_mime(request, fallback)
renderer = RENDERERS_BY_CONTENT_TYPE.get(mt, base)
# Make sure we have something set
renderer = base
render_format = fallback
if request:
# If there is a request, try and get the format
# from the route
if request.route:
try:
if request.route.extra.error_format:
render_format = request.route.extra.error_format
except AttributeError:
...
content_type = request.headers.getone("content-type", "").split(
";"
)[0]
acceptable = request.accept
# If the format is auto still, make a guess
if render_format == "auto":
# First, if there is an Accept header, check if text/html
# is the first option
# According to MDN Web Docs, all major browsers use text/html
# as the primary value in Accept (with the exception of IE 8,
# and, well, if you are supporting IE 8, then you have bigger
# problems to concern yourself with than what default exception
# renderer is used)
# Source:
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation/List_of_default_Accept_values
if acceptable and acceptable[0].match(
"text/html",
allow_type_wildcard=False,
allow_subtype_wildcard=False,
):
renderer = HTMLRenderer
# Second, if there is an Accept header, check if
# application/json is an option, or if the content-type
# is application/json
elif (
acceptable
and acceptable.match(
"application/json",
allow_type_wildcard=False,
allow_subtype_wildcard=False,
)
or content_type == "application/json"
):
renderer = JSONRenderer
# Third, if there is no Accept header, assume we want text.
# The likely use case here is a raw socket.
elif not acceptable:
renderer = TextRenderer
else:
# Fourth, look to see if there was a JSON body
# When in this situation, the request is probably coming
# from curl, an API client like Postman or Insomnia, or a
# package like requests or httpx
try:
# Give them the benefit of the doubt if they did:
# $ curl localhost:8000 -d '{"foo": "bar"}'
# And provide them with JSONRenderer
renderer = JSONRenderer if request.json else base
except BadRequest:
renderer = base
else:
renderer = RENDERERS_BY_CONFIG.get(render_format, renderer)
# Lastly, if there is an Accept header, make sure
# our choice is okay
if acceptable:
type_ = CONTENT_TYPE_BY_RENDERERS.get(renderer) # type: ignore
if type_ and type_ not in acceptable:
# If the renderer selected is not in the Accept header
# look through what is in the Accept header, and select
# the first option that matches. Otherwise, just drop back
# to the original default
for accept in acceptable:
mtype = f"{accept.type_}/{accept.subtype}"
maybe = RENDERERS_BY_CONTENT_TYPE.get(mtype)
if maybe:
renderer = maybe
break
else:
renderer = base
renderer = t.cast(t.Type[BaseRenderer], renderer)
return renderer(request, exception, debug).render()
def guess_mime(req: Request, fallback: str) -> str:
# Attempt to find a suitable MIME format for the response.
# Insertion-ordered map of formats["html"] = "source of that suggestion"
formats = {}
name = ""
# Route error_format (by magic from handler code if auto, the default)
if req.route:
name = req.route.name
f = req.route.extra.error_format
if f in MIME_BY_CONFIG:
formats[f] = name
if not formats and fallback in MIME_BY_CONFIG:
formats[fallback] = "FALLBACK_ERROR_FORMAT"
# If still not known, check for the request for clues of JSON
if not formats and fallback == "auto" and req.accept.match(JSON):
if JSON in req.accept: # Literally, not wildcard
formats["json"] = "request.accept"
elif JSON in req.headers.getone("content-type", ""):
formats["json"] = "content-type"
# DEPRECATION: Remove this block in 24.3
else:
c = None
try:
c = req.json
except BadRequest:
pass
if c:
formats["json"] = "request.json"
deprecation(
"Response type was determined by the JSON content of "
"the request. This behavior is deprecated and will be "
"removed in v24.3. Please specify the format either by\n"
f' error_format="json" on route {name}, by\n'
' FALLBACK_ERROR_FORMAT = "json", or by adding header\n'
" accept: application/json to your requests.",
24.3,
)
# Any other supported formats
if fallback == "auto":
for k in MIME_BY_CONFIG:
if k not in formats:
formats[k] = "any"
mimes = [MIME_BY_CONFIG[k] for k in formats]
m = req.accept.match(*mimes)
if m:
format = CONFIG_BY_MIME[m.mime]
source = formats[format]
logger.debug(
f"The client accepts {m.header}, using '{format}' from {source}"
)
else:
logger.debug(f"No format found, the client accepts {req.accept!r}")
return m.mime

View File

@@ -1,6 +1,5 @@
from asyncio import CancelledError, Protocol
from os import PathLike
from typing import Any, Dict, Optional, Sequence, Union
from asyncio import CancelledError
from typing import Any, Dict, Optional, Union
from sanic.helpers import STATUS_CODES
@@ -10,158 +9,51 @@ class RequestCancelled(CancelledError):
class ServerKilled(Exception):
"""
Exception Sanic server uses when killing a server process for something
unexpected happening.
"""
...
class SanicException(Exception):
"""
Generic exception that will generate an HTTP response when raised
in the context of a request lifecycle.
Usually it is best practice to use one of the more specific exceptions
than this generic. Even when trying to raise a 500, it is generally
preferrable to use :class:`.ServerError`
.. code-block:: python
raise SanicException(
"Something went wrong",
status_code=999,
context={
"info": "Some additional details",
},
headers={
"X-Foo": "bar"
}
)
:param message: The message to be sent to the client. If ``None``
then the appropriate HTTP response status message will be used
instead, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param status_code: The HTTP response code to send, if applicable. If
``None``, then it will be 500, defaults to None
:type status_code: Optional[int], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code: int = 500
quiet: Optional[bool] = False
headers: Dict[str, str] = {}
message: str = ""
def __init__(
self,
message: Optional[Union[str, bytes]] = None,
status_code: Optional[int] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
) -> None:
self.context = context
self.extra = extra
status_code = status_code or getattr(
self.__class__, "status_code", None
)
quiet = quiet or getattr(self.__class__, "quiet", None)
headers = headers or getattr(self.__class__, "headers", {})
if message is None:
if self.message:
message = self.message
elif status_code:
elif status_code is not None:
msg: bytes = STATUS_CODES.get(status_code, b"")
message = msg.decode("utf8")
super().__init__(message)
self.status_code = status_code
self.quiet = quiet
self.headers = headers
if status_code is not None:
self.status_code = status_code
# quiet=None/False/True with None meaning choose by status
if quiet or quiet is None and status_code not in (None, 500):
self.quiet = True
class HTTPException(SanicException):
"""
A base class for other exceptions and should not be called directly.
"""
def __init__(
self,
message: Optional[Union[str, bytes]] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
class NotFound(HTTPException):
class NotFound(SanicException):
"""
**Status**: 404 Not Found
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Not Found' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code = 404
quiet = True
class BadRequest(HTTPException):
class BadRequest(SanicException):
"""
**Status**: 400 Bad Request
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code = 400
@@ -169,133 +61,51 @@ class BadRequest(HTTPException):
InvalidUsage = BadRequest
BadURL = BadRequest
class MethodNotAllowed(HTTPException):
class BadURL(BadRequest):
...
class MethodNotAllowed(SanicException):
"""
**Status**: 405 Method Not Allowed
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Method Not Allowed' will be sent,
defaults to None
:type message: Optional[Union[str, bytes]], optional
:param method: The HTTP method that was used, defaults to an empty string
:type method: Optional[str], optional
:param allowed_methods: The HTTP methods that can be used instead of the
one that was attempted
:type allowed_methods: Optional[Sequence[str]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code = 405
quiet = True
def __init__(
self,
message: Optional[Union[str, bytes]] = None,
method: str = "",
allowed_methods: Optional[Sequence[str]] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
if allowed_methods:
self.headers = {
**self.headers,
"Allow": ", ".join(allowed_methods),
}
self.method = method
self.allowed_methods = allowed_methods
def __init__(self, message, method, allowed_methods):
super().__init__(message)
self.headers = {"Allow": ", ".join(allowed_methods)}
MethodNotSupported = MethodNotAllowed
class ServerError(HTTPException):
class ServerError(SanicException):
"""
**Status**: 500 Internal Server Error
A general server-side error has occurred. If no other HTTP exception is
appropriate, then this should be used
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Internal Server Error' will be sent,
defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code = 500
InternalServerError = ServerError
class ServiceUnavailable(HTTPException):
class ServiceUnavailable(SanicException):
"""
**Status**: 503 Service Unavailable
The server is currently unavailable (because it is overloaded or
down for maintenance). Generally, this is a temporary state.
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code = 503
quiet = True
class URLBuildError(HTTPException):
class URLBuildError(ServerError):
"""
**Status**: 500 Internal Server Error
An exception used by Sanic internals when unable to build a URL.
"""
status_code = 500
@@ -304,77 +114,30 @@ class URLBuildError(HTTPException):
class FileNotFound(NotFound):
"""
**Status**: 404 Not Found
A specific form of :class:`.NotFound` that is specifically when looking
for a file on the file system at a known path.
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Not Found' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param path: The path, if any, to the file that could not
be found, defaults to None
:type path: Optional[PathLike], optional
:param relative_url: A relative URL of the file, defaults to None
:type relative_url: Optional[str], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
def __init__(
self,
message: Optional[Union[str, bytes]] = None,
path: Optional[PathLike] = None,
relative_url: Optional[str] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
def __init__(self, message, path, relative_url):
super().__init__(message)
self.path = path
self.relative_url = relative_url
class RequestTimeout(HTTPException):
"""
The Web server (running the Web site) thinks that there has been too
class RequestTimeout(SanicException):
"""The Web server (running the Web site) thinks that there has been too
long an interval of time between 1) the establishment of an IP
connection (socket) between the client and the server and
2) the receipt of any data on that socket, so the server has dropped
the connection. The socket connection has actually been lost - the Web
server has 'timed out' on that particular socket connection.
This is an internal exception thrown by Sanic and should not be used
directly.
"""
status_code = 408
quiet = True
class PayloadTooLarge(HTTPException):
class PayloadTooLarge(SanicException):
"""
**Status**: 413 Payload Too Large
This is an internal exception thrown by Sanic and should not be used
directly.
"""
status_code = 413
@@ -384,126 +147,34 @@ class PayloadTooLarge(HTTPException):
class HeaderNotFound(BadRequest):
"""
**Status**: 400 Bad Request
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
class InvalidHeader(BadRequest):
"""
**Status**: 400 Bad Request
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
class ContentRange(Protocol):
total: int
class RangeNotSatisfiable(HTTPException):
class RangeNotSatisfiable(SanicException):
"""
**Status**: 416 Range Not Satisfiable
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Range Not Satisfiable' will be sent,
defaults to None
:type message: Optional[Union[str, bytes]], optional
:param content_range: An object meeting the :class:`.ContentRange` protocol
that has a ``total`` property, defaults to None
:type content_range: Optional[ContentRange], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code = 416
quiet = True
def __init__(
self,
message: Optional[Union[str, bytes]] = None,
content_range: Optional[ContentRange] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
if content_range is not None:
self.headers = {
**self.headers,
"Content-Range": f"bytes */{content_range.total}",
}
def __init__(self, message, content_range):
super().__init__(message)
self.headers = {"Content-Range": f"bytes */{content_range.total}"}
ContentRangeError = RangeNotSatisfiable
class ExpectationFailed(HTTPException):
class ExpectationFailed(SanicException):
"""
**Status**: 417 Expectation Failed
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Expectation Failed' will be sent,
defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code = 417
@@ -513,25 +184,9 @@ class ExpectationFailed(HTTPException):
HeaderExpectationFailed = ExpectationFailed
class Forbidden(HTTPException):
class Forbidden(SanicException):
"""
**Status**: 403 Forbidden
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Forbidden' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code = 403
@@ -547,33 +202,20 @@ class InvalidRangeType(RangeNotSatisfiable):
quiet = True
class PyFileError(SanicException):
def __init__(
self,
file,
status_code: Optional[int] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
):
super().__init__(
"could not execute config file %s" % file,
status_code=status_code,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
class PyFileError(Exception):
def __init__(self, file):
super().__init__("could not execute config file %s", file)
class Unauthorized(HTTPException):
class Unauthorized(SanicException):
"""
**Status**: 401 Unauthorized
When present, additional keyword arguments may be used to complete
the WWW-Authentication header.
:param message: Message describing the exception.
:param status_code: HTTP Status code.
:param scheme: Name of the authentication scheme to be used.
When present, kwargs is used to complete the WWW-Authentication header.
Examples::
@@ -598,58 +240,21 @@ class Unauthorized(HTTPException):
raise Unauthorized("Auth required.",
scheme="Bearer",
realm="Restricted Area")
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param scheme: Name of the authentication scheme to be used.
:type scheme: Optional[str], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code = 401
quiet = True
def __init__(
self,
message: Optional[Union[str, bytes]] = None,
scheme: Optional[str] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
**challenges,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
def __init__(self, message, status_code=None, scheme=None, **kwargs):
super().__init__(message, status_code)
# if auth-scheme is specified, set "WWW-Authenticate" header
if scheme is not None:
values = [
'{!s}="{!s}"'.format(k, v) for k, v in challenges.items()
]
values = ['{!s}="{!s}"'.format(k, v) for k, v in kwargs.items()]
challenge = ", ".join(values)
self.headers = {
**self.headers,
"WWW-Authenticate": f"{scheme} {challenge}".rstrip(),
"WWW-Authenticate": f"{scheme} {challenge}".rstrip()
}

View File

@@ -3,8 +3,12 @@ from __future__ import annotations
from typing import Dict, List, Optional, Tuple, Type
from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
from sanic.exceptions import ServerError
from sanic.log import error_logger
from sanic.exceptions import (
HeaderNotFound,
InvalidRangeType,
RangeNotSatisfiable,
)
from sanic.log import deprecation, error_logger
from sanic.models.handler_types import RouteHandler
from sanic.response import text
@@ -19,6 +23,7 @@ class ErrorHandler:
by the developers to perform a wide range of tasks from recording the error
stats to reporting them to an external service that can be used for
realtime alerting system.
"""
def __init__(
@@ -44,11 +49,16 @@ class ErrorHandler:
if name is None:
name = "__ALL_ROUTES__"
message = (
error_logger.warning(
f"Duplicate exception handler definition on: route={name} "
f"and exception={exc}"
)
raise ServerError(message)
deprecation(
"A duplicate exception handler definition was discovered. "
"This may cause unintended consequences. A warning has been "
"issued now, but it will not be allowed starting in v23.3.",
23.3,
)
self.cached_handlers[key] = handler
def add(self, exception, handler, route_names: Optional[List[str]] = None):
@@ -186,3 +196,74 @@ class ErrorHandler:
error_logger.exception(
"Exception occurred while handling uri: %s", url
)
class ContentRangeHandler:
"""
A mechanism to parse and process the incoming request headers to
extract the content range information.
:param request: Incoming api request
:param stats: Stats related to the content
:type request: :class:`sanic.request.Request`
:type stats: :class:`posix.stat_result`
:ivar start: Content Range start
:ivar end: Content Range end
:ivar size: Length of the content
:ivar total: Total size identified by the :class:`posix.stat_result`
instance
:ivar ContentRangeHandler.headers: Content range header ``dict``
"""
__slots__ = ("start", "end", "size", "total", "headers")
def __init__(self, request, stats):
self.total = stats.st_size
_range = request.headers.getone("range", None)
if _range is None:
raise HeaderNotFound("Range Header Not Found")
unit, _, value = tuple(map(str.strip, _range.partition("=")))
if unit != "bytes":
raise InvalidRangeType(
"%s is not a valid Range Type" % (unit,), self
)
start_b, _, end_b = tuple(map(str.strip, value.partition("-")))
try:
self.start = int(start_b) if start_b else None
except ValueError:
raise RangeNotSatisfiable(
"'%s' is invalid for Content Range" % (start_b,), self
)
try:
self.end = int(end_b) if end_b else None
except ValueError:
raise RangeNotSatisfiable(
"'%s' is invalid for Content Range" % (end_b,), self
)
if self.end is None:
if self.start is None:
raise RangeNotSatisfiable(
"Invalid for Content Range parameters", self
)
else:
# this case represents `Content-Range: bytes 5-`
self.end = self.total - 1
else:
if self.start is None:
# this case represents `Content-Range: bytes -5`
self.start = self.total - self.end
self.end = self.total - 1
if self.start >= self.end:
raise RangeNotSatisfiable(
"Invalid for Content Range parameters", self
)
self.size = self.end - self.start + 1
self.headers = {
"Content-Range": "bytes %s-%s/%s"
% (self.start, self.end, self.total)
}
def __bool__(self):
return self.size > 0

View File

@@ -1,10 +0,0 @@
from .content_range import ContentRangeHandler
from .directory import DirectoryHandler
from .error import ErrorHandler
__all__ = (
"ContentRangeHandler",
"DirectoryHandler",
"ErrorHandler",
)

View File

@@ -1,78 +0,0 @@
from __future__ import annotations
from sanic.exceptions import (
HeaderNotFound,
InvalidRangeType,
RangeNotSatisfiable,
)
class ContentRangeHandler:
"""
A mechanism to parse and process the incoming request headers to
extract the content range information.
:param request: Incoming api request
:param stats: Stats related to the content
:type request: :class:`sanic.request.Request`
:type stats: :class:`posix.stat_result`
:ivar start: Content Range start
:ivar end: Content Range end
:ivar size: Length of the content
:ivar total: Total size identified by the :class:`posix.stat_result`
instance
:ivar ContentRangeHandler.headers: Content range header ``dict``
"""
__slots__ = ("start", "end", "size", "total", "headers")
def __init__(self, request, stats):
self.total = stats.st_size
_range = request.headers.getone("range", None)
if _range is None:
raise HeaderNotFound("Range Header Not Found")
unit, _, value = tuple(map(str.strip, _range.partition("=")))
if unit != "bytes":
raise InvalidRangeType(
"%s is not a valid Range Type" % (unit,), self
)
start_b, _, end_b = tuple(map(str.strip, value.partition("-")))
try:
self.start = int(start_b) if start_b else None
except ValueError:
raise RangeNotSatisfiable(
"'%s' is invalid for Content Range" % (start_b,), self
)
try:
self.end = int(end_b) if end_b else None
except ValueError:
raise RangeNotSatisfiable(
"'%s' is invalid for Content Range" % (end_b,), self
)
if self.end is None:
if self.start is None:
raise RangeNotSatisfiable(
"Invalid for Content Range parameters", self
)
else:
# this case represents `Content-Range: bytes 5-`
self.end = self.total - 1
else:
if self.start is None:
# this case represents `Content-Range: bytes -5`
self.start = self.total - self.end
self.end = self.total - 1
if self.start >= self.end:
raise RangeNotSatisfiable(
"Invalid for Content Range parameters", self
)
self.size = self.end - self.start + 1
self.headers = {
"Content-Range": "bytes %s-%s/%s"
% (self.start, self.end, self.total)
}
def __bool__(self):
return hasattr(self, "size") and self.size > 0

View File

@@ -1,84 +0,0 @@
from __future__ import annotations
from datetime import datetime
from operator import itemgetter
from pathlib import Path
from stat import S_ISDIR
from typing import Dict, Iterable, Optional, Sequence, Union, cast
from sanic.exceptions import NotFound
from sanic.pages.directory_page import DirectoryPage, FileInfo
from sanic.request import Request
from sanic.response import file, html, redirect
class DirectoryHandler:
def __init__(
self,
uri: str,
directory: Path,
directory_view: bool = False,
index: Optional[Union[str, Sequence[str]]] = None,
) -> None:
if isinstance(index, str):
index = [index]
elif index is None:
index = []
self.base = uri.strip("/")
self.directory = directory
self.directory_view = directory_view
self.index = tuple(index)
async def handle(self, request: Request, path: str):
current = path.strip("/")[len(self.base) :].strip("/") # noqa: E203
for file_name in self.index:
index_file = self.directory / current / file_name
if index_file.is_file():
return await file(index_file)
if self.directory_view:
return self._index(
self.directory / current, path, request.app.debug
)
if self.index:
raise NotFound("File not found")
raise IsADirectoryError(f"{self.directory.as_posix()} is a directory")
def _index(self, location: Path, path: str, debug: bool):
# Remove empty path elements, append slash
if "//" in path or not path.endswith("/"):
return redirect(
"/" + "".join([f"{p}/" for p in path.split("/") if p])
)
# Render file browser
page = DirectoryPage(self._iter_files(location), path, debug)
return html(page.render())
def _prepare_file(self, path: Path) -> Dict[str, Union[int, str]]:
stat = path.stat()
modified = (
datetime.fromtimestamp(stat.st_mtime)
.isoformat()[:19]
.replace("T", " ")
)
is_dir = S_ISDIR(stat.st_mode)
icon = "📁" if is_dir else "📄"
file_name = path.name
if is_dir:
file_name += "/"
return {
"priority": is_dir * -1,
"file_name": file_name,
"icon": icon,
"file_access": modified,
"file_size": stat.st_size,
}
def _iter_files(self, location: Path) -> Iterable[FileInfo]:
prepared = [self._prepare_file(f) for f in location.iterdir()]
for item in sorted(prepared, key=itemgetter("priority", "file_name")):
del item["priority"]
yield cast(FileInfo, item)

View File

@@ -19,6 +19,7 @@ OptionsIterable = Iterable[Tuple[str, str]] # May contain duplicate keys
_token, _quoted = r"([\w!#$%&'*+\-.^_`|~]+)", r'"([^"]*)"'
_param = re.compile(rf";\s*{_token}=(?:{_token}|{_quoted})", re.ASCII)
_firefox_quote_escape = re.compile(r'\\"(?!; |\s*$)')
_ipv6 = "(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}"
_ipv6_re = re.compile(_ipv6)
_host_re = re.compile(
@@ -32,96 +33,143 @@ _host_re = re.compile(
# For more information, consult ../tests/test_requests.py
class MediaType:
"""A media type, as used in the Accept header."""
def parse_arg_as_accept(f):
def func(self, other, *args, **kwargs):
if not isinstance(other, Accept) and other:
other = Accept.parse(other)
return f(self, other, *args, **kwargs)
return func
class MediaType(str):
def __new__(cls, value: str):
return str.__new__(cls, value)
def __init__(self, value: str) -> None:
self.value = value
self.is_wildcard = self.check_if_wildcard(value)
def __eq__(self, other):
if self.is_wildcard:
return True
if self.match(other):
return True
other_is_wildcard = (
other.is_wildcard
if isinstance(other, MediaType)
else self.check_if_wildcard(other)
)
return other_is_wildcard
def match(self, other):
other_value = other.value if isinstance(other, MediaType) else other
return self.value == other_value
@staticmethod
def check_if_wildcard(value):
return value == "*"
class Accept(str):
def __new__(cls, value: str, *args, **kwargs):
return str.__new__(cls, value)
def __init__(
self,
type_: str,
subtype: str,
**params: str,
value: str,
type_: MediaType,
subtype: MediaType,
*,
q: str = "1.0",
**kwargs: str,
):
self.type = type_
qvalue = float(q)
if qvalue > 1 or qvalue < 0:
raise InvalidHeader(
f"Accept header qvalue must be between 0 and 1, not: {qvalue}"
)
self.value = value
self.type_ = type_
self.subtype = subtype
self.q = float(params.get("q", "1.0"))
self.params = params
self.mime = f"{type_}/{subtype}"
self.key = (
-1 * self.q,
-1 * len(self.params),
self.subtype == "*",
self.type == "*",
)
self.qvalue = qvalue
self.params = kwargs
def __repr__(self):
return self.mime + "".join(f";{k}={v}" for k, v in self.params.items())
def _compare(self, other, method):
try:
return method(self.qvalue, other.qvalue)
except (AttributeError, TypeError):
return NotImplemented
def __eq__(self, other):
"""Check for mime (str or MediaType) identical type/subtype.
Parameters such as q are not considered."""
if isinstance(other, str):
# Give a friendly reminder if str contains parameters
if ";" in other:
raise ValueError("Use match() to compare with parameters")
return self.mime == other
if isinstance(other, MediaType):
# Ignore parameters silently with MediaType objects
return self.mime == other.mime
return NotImplemented
@parse_arg_as_accept
def __lt__(self, other: Union[str, Accept]):
return self._compare(other, lambda s, o: s < o)
@parse_arg_as_accept
def __le__(self, other: Union[str, Accept]):
return self._compare(other, lambda s, o: s <= o)
@parse_arg_as_accept
def __eq__(self, other: Union[str, Accept]): # type: ignore
return self._compare(other, lambda s, o: s == o)
@parse_arg_as_accept
def __ge__(self, other: Union[str, Accept]):
return self._compare(other, lambda s, o: s >= o)
@parse_arg_as_accept
def __gt__(self, other: Union[str, Accept]):
return self._compare(other, lambda s, o: s > o)
@parse_arg_as_accept
def __ne__(self, other: Union[str, Accept]): # type: ignore
return self._compare(other, lambda s, o: s != o)
@parse_arg_as_accept
def match(
self,
mime_with_params: Union[str, MediaType],
) -> Optional[MediaType]:
"""Check if this media type matches the given mime type/subtype.
Wildcards are supported both ways on both type and subtype.
If mime contains a semicolon, optionally followed by parameters,
the parameters of the two media types must match exactly.
Note: Use the `==` operator instead to check for literal matches
without expanding wildcards.
@param media_type: A type/subtype string to match.
@return `self` if the media types are compatible, else `None`
"""
mt = (
MediaType._parse(mime_with_params)
if isinstance(mime_with_params, str)
else mime_with_params
)
return (
self
if (
mt
# All parameters given in the other media type must match
and all(self.params.get(k) == v for k, v in mt.params.items())
# Subtype match
and (
self.subtype == mt.subtype
or self.subtype == "*"
or mt.subtype == "*"
)
# Type match
and (
self.type == mt.type or self.type == "*" or mt.type == "*"
)
other,
*,
allow_type_wildcard: bool = True,
allow_subtype_wildcard: bool = True,
) -> bool:
type_match = (
self.type_ == other.type_
if allow_type_wildcard
else (
self.type_.match(other.type_)
and not self.type_.is_wildcard
and not other.type_.is_wildcard
)
)
subtype_match = (
self.subtype == other.subtype
if allow_subtype_wildcard
else (
self.subtype.match(other.subtype)
and not self.subtype.is_wildcard
and not other.subtype.is_wildcard
)
else None
)
@property
def has_wildcard(self) -> bool:
"""Return True if this media type has a wildcard in it."""
return any(part == "*" for part in (self.subtype, self.type))
return type_match and subtype_match
@classmethod
def _parse(cls, mime_with_params: str) -> Optional[MediaType]:
mtype = mime_with_params.strip()
if "/" not in mime_with_params:
return None
def parse(cls, raw: str) -> Accept:
invalid = False
mtype = raw.strip()
mime, *raw_params = mtype.split(";")
type_, subtype = mime.split("/", 1)
if not type_ or not subtype:
raise ValueError(f"Invalid media type: {mtype}")
try:
media, *raw_params = mtype.split(";")
type_, subtype = media.split("/")
except ValueError:
invalid = True
if invalid or not type_ or not subtype:
raise InvalidHeader(f"Header contains invalid Accept value: {raw}")
params = dict(
[
@@ -130,160 +178,46 @@ class MediaType:
]
)
return cls(type_.lstrip(), subtype.rstrip(), **params)
return cls(mtype, MediaType(type_), MediaType(subtype), **params)
class Matched:
"""A matching result of a MIME string against a header."""
class AcceptContainer(list):
def __contains__(self, o: object) -> bool:
return any(item.match(o) for item in self)
def __init__(self, mime: str, header: Optional[MediaType]):
self.mime = mime
self.header = header
def __repr__(self):
return f"<{self} matched {self.header}>" if self else "<no match>"
def __str__(self):
return self.mime
def __bool__(self):
return self.header is not None
def __eq__(self, other: Any) -> bool:
try:
comp, other_accept = self._compare(other)
except TypeError:
return False
return bool(
comp
and (
(self.header and other_accept.header)
or (not self.header and not other_accept.header)
def match(
self,
o: object,
*,
allow_type_wildcard: bool = True,
allow_subtype_wildcard: bool = True,
) -> bool:
return any(
item.match(
o,
allow_type_wildcard=allow_type_wildcard,
allow_subtype_wildcard=allow_subtype_wildcard,
)
for item in self
)
def _compare(self, other) -> Tuple[bool, Matched]:
if isinstance(other, str):
parsed = Matched.parse(other)
if self.mime == other:
return True, parsed
other = parsed
if isinstance(other, Matched):
return self.header == other.header, other
raise TypeError(
"Comparison not supported between unequal "
f"mime types of '{self.mime}' and '{other}'"
)
def match(self, other: Union[str, Matched]) -> Optional[Matched]:
accept = Matched.parse(other) if isinstance(other, str) else other
if not self.header or not accept.header:
return None
if self.header.match(accept.header):
return accept
return None
@classmethod
def parse(cls, raw: str) -> Matched:
media_type = MediaType._parse(raw)
return cls(raw, media_type)
class AcceptList(list):
"""A list of media types, as used in the Accept header.
The Accept header entries are listed in order of preference, starting
with the most preferred. This class is a list of `MediaType` objects,
that encapsulate also the q value or any other parameters.
Two separate methods are provided for searching the list:
- 'match' for finding the most preferred match (wildcards supported)
- operator 'in' for checking explicit matches (wildcards as literals)
"""
def match(self, *mimes: str, accept_wildcards=True) -> Matched:
"""Find a media type accepted by the client.
This method can be used to find which of the media types requested by
the client is most preferred against the ones given as arguments.
The ordering of preference is set by:
1. The order set by RFC 7231, s. 5.3.2, giving a higher priority
to q values and more specific type definitions,
2. The order of the arguments (first is most preferred), and
3. The first matching entry on the Accept header.
Wildcards are matched both ways. A match is usually found, as the
Accept headers typically include `*/*`, in particular if the header
is missing, is not manually set, or if the client is a browser.
Note: the returned object behaves as a string of the mime argument
that matched, and is empty/falsy if no match was found. The matched
header entry `MediaType` or `None` is available as the `m` attribute.
@param mimes: Any MIME types to search for in order of preference.
@param accept_wildcards: Match Accept entries with wildcards in them.
@return A match object with the mime string and the MediaType object.
"""
a = sorted(
(-acc.q, i, j, mime, acc)
for j, acc in enumerate(self)
if accept_wildcards or not acc.has_wildcard
for i, mime in enumerate(mimes)
if acc.match(mime)
)
return Matched(*(a[0][-2:] if a else ("", None)))
def __str__(self):
"""Format as Accept header value (parsed, not original)."""
return ", ".join(str(m) for m in self)
def parse_accept(accept: Optional[str]) -> AcceptList:
"""Parse an Accept header and order the acceptable media types in
according to RFC 7231, s. 5.3.2
https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2
"""
if not accept:
if accept == "":
return AcceptList() # Empty header, accept nothing
accept = "*/*" # No header means that all types are accepted
try:
a = [
mt
for mt in [MediaType._parse(mtype) for mtype in accept.split(",")]
if mt
]
if not a:
raise ValueError
return AcceptList(sorted(a, key=lambda x: x.key))
except ValueError:
raise InvalidHeader(f"Invalid header value in Accept: {accept}")
def parse_content_header(value: str) -> Tuple[str, Options]:
"""Parse content-type and content-disposition header values.
E.g. `form-data; name=upload; filename="file.txt"` to
E.g. 'form-data; name=upload; filename=\"file.txt\"' to
('form-data', {'name': 'upload', 'filename': 'file.txt'})
Mostly identical to cgi.parse_header and werkzeug.parse_options_header
but runs faster and handles special characters better.
Unescapes %22 to `"` and %0D%0A to `\n` in field values.
but runs faster and handles special characters better. Unescapes quotes.
"""
value = _firefox_quote_escape.sub("%22", value)
pos = value.find(";")
if pos == -1:
options: Dict[str, Union[int, str]] = {}
else:
options = {
m.group(1)
.lower(): (m.group(2) or m.group(3))
.replace("%22", '"')
.replace("%0D%0A", "\n")
m.group(1).lower(): m.group(2) or m.group(3).replace("%22", '"')
for m in _param.finditer(value[pos:])
}
value = value[:pos]
@@ -434,6 +368,34 @@ def format_http1_response(status: int, headers: HeaderBytesIterable) -> bytes:
return ret
def _sort_accept_value(accept: Accept):
return (
accept.qvalue,
len(accept.params),
accept.subtype != "*",
accept.type_ != "*",
)
def parse_accept(accept: str) -> AcceptContainer:
"""Parse an Accept header and order the acceptable media types in
accorsing to RFC 7231, s. 5.3.2
https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2
"""
media_types = accept.split(",")
accept_list: List[Accept] = []
for mtype in media_types:
if not mtype:
continue
accept_list.append(Accept.parse(mtype))
return AcceptContainer(
sorted(accept_list, key=_sort_accept_value, reverse=True)
)
def parse_credentials(
header: Optional[str],
prefixes: Union[List, Tuple, Set] = None,

View File

@@ -240,14 +240,9 @@ class Http(Stream, metaclass=TouchUpMeta):
headers_instance.getone("upgrade", "").lower() == "websocket"
)
try:
url_bytes = self.url.encode("ASCII")
except UnicodeEncodeError:
raise BadRequest("URL may only contain US-ASCII characters.")
# Prepare a Request object
request = self.protocol.request_class(
url_bytes=url_bytes,
url_bytes=self.url.encode(),
headers=headers_instance,
head=bytes(head),
version=protocol[5:],
@@ -433,9 +428,7 @@ class Http(Stream, metaclass=TouchUpMeta):
if self.request is None:
self.create_empty_request()
request_middleware = not isinstance(
exception, (ServiceUnavailable, RequestCancelled)
)
request_middleware = not isinstance(exception, ServiceUnavailable)
try:
await app.handle_exception(
self.request, exception, request_middleware
@@ -450,18 +443,9 @@ class Http(Stream, metaclass=TouchUpMeta):
bogus response for error handling use.
"""
# Reformat any URL already received with \xHH escapes for better logs
url_bytes = (
self.url.encode(errors="surrogateescape")
.decode("ASCII", errors="backslashreplace")
.encode("ASCII")
if self.url
else b"*"
)
# FIXME: Avoid this by refactoring error handling and response code
self.request = self.protocol.request_class(
url_bytes=url_bytes,
url_bytes=self.url.encode() if self.url else b"*",
headers=Header({}),
version="1.1",
method="NONE",

View File

@@ -18,12 +18,7 @@ from typing import (
from sanic.compat import Header
from sanic.constants import LocalCertCreator
from sanic.exceptions import (
BadRequest,
PayloadTooLarge,
SanicException,
ServerError,
)
from sanic.exceptions import PayloadTooLarge, SanicException, ServerError
from sanic.helpers import has_message_body
from sanic.http.constants import Stage
from sanic.http.stream import Stream
@@ -338,17 +333,7 @@ class Http3:
return self.receivers[stream_id]
def _make_request(self, event: HeadersReceived) -> Request:
try:
headers = Header(
(
(k.decode("ASCII"), v.decode(errors="surrogateescape"))
for k, v in event.headers
)
)
except UnicodeDecodeError:
raise BadRequest(
"Header names may only contain US-ASCII characters."
)
headers = Header(((k.decode(), v.decode()) for k, v in event.headers))
method = headers[":method"]
path = headers[":path"]
scheme = headers.pop(":scheme", "")
@@ -357,14 +342,9 @@ class Http3:
if authority:
headers["host"] = authority
try:
url_bytes = path.encode("ASCII")
except UnicodeEncodeError:
raise BadRequest("URL may only contain US-ASCII characters.")
transport = HTTP3Transport(self.protocol)
request = self.protocol.request_class(
url_bytes,
path.encode(),
headers,
"3",
method,

View File

@@ -159,7 +159,7 @@ class CertSimple(SanicSSLContext):
# try common aliases, rename to cert/key
certfile = kw["cert"] = kw.pop("certificate", None) or cert
keyfile = kw["key"] = kw.pop("keyfile", None) or key
password = kw.get("password", None)
password = kw.pop("password", None)
if not certfile or not keyfile:
raise ValueError("SSL dict needs filenames for cert and key.")
subject = {}

View File

@@ -62,13 +62,13 @@ LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
},
formatters={
"generic": {
"format": "%(asctime)s [%(process)s] [%(levelname)s] %(message)s",
"format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s",
"datefmt": "[%Y-%m-%d %H:%M:%S %z]",
"class": "logging.Formatter",
},
"access": {
"format": "%(asctime)s - (%(name)s)[%(levelname)s][%(host)s]: "
+ "%(request)s %(message)s %(status)s %(byte)s",
+ "%(request)s %(message)s %(status)d %(byte)d",
"datefmt": "[%Y-%m-%d %H:%M:%S %z]",
"class": "logging.Formatter",
},
@@ -126,26 +126,7 @@ logger.addFilter(_verbosity_filter)
def deprecation(message: str, version: float): # no cov
"""
Add a deprecation notice
Example when a feature is being removed. In this case, version
should be AT LEAST next version + 2
deprecation("Helpful message", 99.9)
Example when a feature is deprecated but not being removed:
deprecation("Helpful message", 0)
:param message: The message of the notice
:type message: str
:param version: The version when the feature will be removed. If it is
not being removed, then set version=0.
:type version: float
"""
version_display = f" v{version}" if version else ""
version_info = f"[DEPRECATION{version_display}] "
version_info = f"[DEPRECATION v{version}] "
if is_atty():
version_info = f"{Colors.RED}{version_info}"
message = f"{Colors.YELLOW}{message}{Colors.END}"

View File

@@ -1,35 +0,0 @@
from typing import Optional
from sanic.base.meta import SanicMeta
class BaseMixin(metaclass=SanicMeta):
name: str
strict_slashes: Optional[bool]
def _generate_name(self, *objects) -> str:
name = None
for obj in objects:
if obj:
if isinstance(obj, str):
name = obj
break
try:
name = obj.name
except AttributeError:
try:
name = obj.__name__
except AttributeError:
continue
else:
break
if not name: # noqa
raise ValueError("Could not generate a name for handler")
if not name.startswith(f"{self.name}."):
name = f"{self.name}.{name}"
return name

View File

@@ -1,6 +1,11 @@
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 typing import (
Any,
@@ -14,15 +19,20 @@ from typing import (
Union,
cast,
)
from urllib.parse import unquote
from sanic_routing.route import Route
from sanic.base.meta import SanicMeta
from sanic.constants import HTTP_METHODS
from sanic.compat import stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE, HTTP_METHODS
from sanic.errorpages import RESPONSE_MAPPING
from sanic.mixins.base import BaseMixin
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
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, validate_file
from sanic.types import HashableDict
@@ -31,14 +41,20 @@ RouteWrapper = Callable[
]
class RouteMixin(BaseMixin, metaclass=SanicMeta):
class RouteMixin(metaclass=SanicMeta):
name: str
def __init__(self, *args, **kwargs) -> None:
self._future_routes: Set[FutureRoute] = set()
self._future_statics: Set[FutureStatic] = set()
self.strict_slashes: Optional[bool] = False
def _apply_route(self, route: FutureRoute) -> List[Route]:
raise NotImplementedError # noqa
def _apply_static(self, static: FutureStatic) -> Route:
raise NotImplementedError # noqa
def route(
self,
uri: str,
@@ -546,7 +562,7 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
strict_slashes: Optional[bool] = None,
version: Optional[Union[int, str, float]] = None,
name: Optional[str] = None,
ignore_body: bool = False,
ignore_body: bool = True,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs: Any,
@@ -672,6 +688,324 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
**ctx_kwargs,
)(handler)
def static(
self,
uri: str,
file_or_directory: Union[str, bytes, PurePath],
pattern: str = r"/?.+",
use_modified_since: bool = True,
use_content_range: bool = False,
stream_large_files: bool = False,
name: str = "static",
host: Optional[str] = None,
strict_slashes: Optional[bool] = None,
content_type: Optional[bool] = None,
apply: bool = True,
resource_type: Optional[str] = None,
):
"""
Register a root to serve files from. The input can either be a
file or a directory. This method will enable an easy and simple way
to setup the :class:`Route` necessary to serve the static files.
:param uri: URL path to be used for serving static content
:param file_or_directory: Path for the Static file/directory with
static files
:param pattern: Regex Pattern identifying the valid static files
:param use_modified_since: If true, send file modified time, and return
not modified if the browser's matches the server's
:param use_content_range: If true, process header for range requests
and sends the file part that is requested
:param stream_large_files: If true, use the
:func:`StreamingHTTPResponse.file_stream` handler rather
than the :func:`HTTPResponse.file` handler to send the file.
If this is an integer, this represents the threshold size to
switch to :func:`StreamingHTTPResponse.file_stream`
:param name: user defined name used for url_for
:param host: Host IP or FQDN for the service to use
:param strict_slashes: Instruct :class:`Sanic` to check if the request
URLs need to terminate with a */*
:param content_type: user defined content type for header
:return: routes registered on the router
:rtype: List[sanic.router.Route]
"""
name = self._generate_name(name)
if strict_slashes is None and self.strict_slashes is not None:
strict_slashes = self.strict_slashes
if not isinstance(file_or_directory, (str, bytes, PurePath)):
raise ValueError(
f"Static route must be a valid path, not {file_or_directory}"
)
static = FutureStatic(
uri,
file_or_directory,
pattern,
use_modified_since,
use_content_range,
stream_large_files,
name,
host,
strict_slashes,
content_type,
resource_type,
)
self._future_statics.add(static)
if apply:
self._apply_static(static)
def _generate_name(self, *objects) -> str:
name = None
for obj in objects:
if obj:
if isinstance(obj, str):
name = obj
break
try:
name = obj.name
except AttributeError:
try:
name = obj.__name__
except AttributeError:
continue
else:
break
if not name: # noqa
raise ValueError("Could not generate a name for handler")
if not name.startswith(f"{self.name}."):
name = f"{self.name}.{name}"
return name
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()
if __file_uri__:
# Strip all / that in the beginning of the URL to help prevent
# python from herping a derp and treating the uri as an
# absolute path
unquoted_file_uri = unquote(__file_uri__).lstrip("/")
file_path_raw = Path(file_or_directory, unquoted_file_uri)
file_path = file_path_raw.resolve()
if (
file_path < root_path and not file_path_raw.is_symlink()
) or ".." in file_path_raw.parts:
error_logger.exception(
f"File not found: path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise not_found
try:
file_path.relative_to(root_path)
except ValueError:
if not file_path_raw.is_symlink():
error_logger.exception(
f"File not found: path={file_or_directory}, "
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
# and it has not been modified since
stats = None
if use_modified_since:
stats = await stat_async(file_path)
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
)
_range = None
if use_content_range:
_range = None
if not stats:
stats = await stat_async(file_path)
headers["Accept-Ranges"] = "bytes"
headers["Content-Length"] = str(stats.st_size)
if request.method != "HEAD":
try:
_range = ContentRangeHandler(request, stats)
except HeaderNotFound:
pass
else:
del headers["Content-Length"]
headers.update(_range.headers)
if "content-type" not in headers:
content_type = (
content_type
or guess_type(file_path)[0]
or DEFAULT_HTTP_CONTENT_TYPE
)
if "charset=" not in content_type and (
content_type.startswith("text/")
or content_type == "application/javascript"
):
content_type += "; charset=utf-8"
headers["Content-Type"] = content_type
if request.method == "HEAD":
return HTTPResponse(headers=headers)
else:
if stream_large_files:
if type(stream_large_files) == int:
threshold = stream_large_files
else:
threshold = 1024 * 1024
if not stats:
stats = await stat_async(file_path)
if stats.st_size >= threshold:
return await file_stream(
file_path, headers=headers, _range=_range
)
return await file(file_path, headers=headers, _range=_range)
except RangeNotSatisfiable:
raise
except FileNotFoundError:
raise not_found
except Exception:
error_logger.exception(
f"Exception in static request handler: "
f"path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise
def _register_static(
self,
static: FutureStatic,
):
# TODO: Though sanic is not a file server, I feel like we should
# at least make a good effort here. Modified-since is nice, but
# we could also look into etags, expires, and caching
"""
Register a static directory handler with Sanic by adding a route to the
router and registering a handler.
:param app: Sanic
:param file_or_directory: File or directory path to serve from
:type file_or_directory: Union[str,bytes,Path]
:param uri: URL to serve from
:type uri: str
:param pattern: regular expression used to match files in the URL
:param use_modified_since: If true, send file modified time, and return
not modified if the browser's matches the
server's
:param use_content_range: If true, process header for range requests
and sends the file part that is requested
:param stream_large_files: If true, use the file_stream() handler
rather than the file() handler to send the file
If this is an integer, this represents the
threshold size to switch to file_stream()
:param name: user defined name used for url_for
:type name: str
:param content_type: user defined content type for header
:return: registered static routes
:rtype: List[sanic.router.Route]
"""
if isinstance(static.file_or_directory, bytes):
file_or_directory = static.file_or_directory.decode("utf-8")
elif isinstance(static.file_or_directory, PurePath):
file_or_directory = str(static.file_or_directory)
elif not isinstance(static.file_or_directory, str):
raise ValueError("Invalid file path string.")
else:
file_or_directory = static.file_or_directory
uri = static.uri
name = static.name
# If we're not trying to match a file directly,
# serve from the folder
if not static.resource_type:
if not path.isfile(file_or_directory):
uri = uri.rstrip("/")
uri += "/<__file_uri__:path>"
elif static.resource_type == "dir":
if path.isfile(file_or_directory):
raise TypeError(
"Resource type improperly identified as directory. "
f"'{file_or_directory}'"
)
uri = uri.rstrip("/")
uri += "/<__file_uri__:path>"
elif static.resource_type == "file" and not path.isfile(
file_or_directory
):
raise TypeError(
"Resource type improperly identified as file. "
f"'{file_or_directory}'"
)
elif static.resource_type != "file":
raise ValueError(
"The resource_type should be set to 'file' or 'dir'"
)
# special prefix for static files
# if not static.name.startswith("_static_"):
# name = f"_static_{static.name}"
_handler = wraps(self._static_request_handler)(
partial(
self._static_request_handler,
file_or_directory,
static.use_modified_since,
static.use_content_range,
static.stream_large_files,
content_type=static.content_type,
)
)
route, _ = self.route( # type: ignore
uri=uri,
methods=["GET", "HEAD"],
name=name,
host=static.host,
strict_slashes=static.strict_slashes,
static=True,
)(_handler)
return route
def _determine_error_format(self, handler) -> str:
with suppress(OSError, TypeError):
src = dedent(getsource(handler))

View File

@@ -47,16 +47,17 @@ from sanic.helpers import Default, _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
from sanic.log import Colors, error_logger, logger
from sanic.log import Colors, deprecation, error_logger, logger
from sanic.models.handler_types import ListenerType
from sanic.server import Signal as ServerSignal
from sanic.server import try_use_uvloop
from sanic.server.async_server import AsyncioServer
from sanic.server.events import trigger_events
from sanic.server.legacy import watchdog
from sanic.server.loop import try_windows_loop
from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.protocols.websocket_protocol import WebSocketProtocol
from sanic.server.runners import serve
from sanic.server.runners import serve, serve_multiple, serve_single
from sanic.server.socket import configure_socket, remove_unix_socket
from sanic.worker.loader import AppLoader
from sanic.worker.manager import WorkerManager
@@ -134,6 +135,7 @@ class StartupMixin(metaclass=SanicMeta):
motd_display: Optional[Dict[str, str]] = None,
auto_tls: bool = False,
single_process: bool = False,
legacy: bool = False,
) -> None:
"""
Run the HTTP Server and listen until keyboard interrupt or term
@@ -195,10 +197,13 @@ class StartupMixin(metaclass=SanicMeta):
motd_display=motd_display,
auto_tls=auto_tls,
single_process=single_process,
legacy=legacy,
)
if single_process:
serve = self.__class__.serve_single
elif legacy:
serve = self.__class__.serve_legacy
else:
serve = self.__class__.serve
serve(primary=self) # type: ignore
@@ -230,6 +235,7 @@ class StartupMixin(metaclass=SanicMeta):
coffee: bool = False,
auto_tls: bool = False,
single_process: bool = False,
legacy: bool = False,
) -> None:
if version == 3 and self.state.server_info:
raise RuntimeError(
@@ -258,10 +264,13 @@ class StartupMixin(metaclass=SanicMeta):
"or auto-reload"
)
if register_sys_signals is False and not single_process:
if single_process and legacy:
raise RuntimeError("Cannot run single process and legacy mode")
if register_sys_signals is False and not (single_process or legacy):
raise RuntimeError(
"Cannot run Sanic.serve with register_sys_signals=False. "
"Use Sanic.serve_single."
"Use either Sanic.serve_single or Sanic.serve_legacy."
)
if motd_display:
@@ -802,7 +811,7 @@ class StartupMixin(metaclass=SanicMeta):
ssl = kwargs.get("ssl")
if isinstance(ssl, SanicSSLContext):
kwargs["ssl"] = ssl.sanic
kwargs["ssl"] = kwargs["ssl"].sanic
manager = WorkerManager(
primary.state.workers,
@@ -868,10 +877,7 @@ class StartupMixin(metaclass=SanicMeta):
sync_manager.shutdown()
for sock in socks:
try:
sock.shutdown(SHUT_RDWR)
except OSError:
...
sock.shutdown(SHUT_RDWR)
sock.close()
socks = []
trigger_events(main_stop, loop, primary)
@@ -947,6 +953,76 @@ class StartupMixin(metaclass=SanicMeta):
cls._cleanup_env_vars()
cls._cleanup_apps()
@classmethod
def serve_legacy(cls, primary: Optional[Sanic] = None) -> None:
apps = list(cls._app_registry.values())
if not primary:
try:
primary = apps[0]
except IndexError:
raise RuntimeError("Did not find any applications.")
reloader_start = primary.listeners.get("reload_process_start")
reloader_stop = primary.listeners.get("reload_process_stop")
# We want to run auto_reload if ANY of the applications have it enabled
if (
cls.should_auto_reload()
and os.environ.get("SANIC_SERVER_RUNNING") != "true"
): # no cov
loop = new_event_loop()
trigger_events(reloader_start, loop, primary)
reload_dirs: Set[Path] = primary.state.reload_dirs.union(
*(app.state.reload_dirs for app in apps)
)
watchdog(1.0, reload_dirs)
trigger_events(reloader_stop, loop, primary)
return
# This exists primarily for unit testing
if not primary.state.server_info: # no cov
for app in apps:
app.state.server_info.clear()
return
primary_server_info = primary.state.server_info[0]
primary.before_server_start(partial(primary._start_servers, apps=apps))
deprecation(
f"{Colors.YELLOW}Running {Colors.SANIC}Sanic {Colors.YELLOW}w/ "
f"LEGACY manager.{Colors.END} Support for will be dropped in "
"version 23.3.",
23.3,
)
try:
primary_server_info.stage = ServerStage.SERVING
if primary.state.workers > 1 and os.name != "posix": # no cov
logger.warning(
f"Multiprocessing is currently not supported on {os.name},"
" using workers=1 instead"
)
primary.state.workers = 1
if primary.state.workers == 1:
serve_single(primary_server_info.settings)
elif primary.state.workers == 0:
raise RuntimeError("Cannot serve with no workers")
else:
serve_multiple(
primary_server_info.settings, primary.state.workers
)
except BaseException:
error_logger.exception(
"Experienced exception while trying to serve"
)
raise
finally:
primary_server_info.stage = ServerStage.STOPPED
logger.info("Server Stopped")
cls._cleanup_env_vars()
cls._cleanup_apps()
async def _start_servers(
self,
primary: Sanic,

View File

@@ -1,346 +0,0 @@
from email.utils import formatdate
from functools import partial, wraps
from mimetypes import guess_type
from os import PathLike, path
from pathlib import Path, PurePath
from typing import Optional, Sequence, Set, Union
from urllib.parse import unquote
from sanic_routing.route import Route
from sanic.base.meta import SanicMeta
from sanic.compat import stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
from sanic.handlers import ContentRangeHandler
from sanic.handlers.directory import DirectoryHandler
from sanic.log import error_logger
from sanic.mixins.base import BaseMixin
from sanic.models.futures import FutureStatic
from sanic.request import Request
from sanic.response import HTTPResponse, file, file_stream, validate_file
class StaticMixin(BaseMixin, metaclass=SanicMeta):
def __init__(self, *args, **kwargs) -> None:
self._future_statics: Set[FutureStatic] = set()
def _apply_static(self, static: FutureStatic) -> Route:
raise NotImplementedError # noqa
def static(
self,
uri: str,
file_or_directory: Union[PathLike, str],
pattern: str = r"/?.+",
use_modified_since: bool = True,
use_content_range: bool = False,
stream_large_files: Union[bool, int] = False,
name: str = "static",
host: Optional[str] = None,
strict_slashes: Optional[bool] = None,
content_type: Optional[str] = None,
apply: bool = True,
resource_type: Optional[str] = None,
index: Optional[Union[str, Sequence[str]]] = None,
directory_view: bool = False,
directory_handler: Optional[DirectoryHandler] = None,
):
"""
Register a root to serve files from. The input can either be a
file or a directory. This method will enable an easy and simple way
to setup the :class:`Route` necessary to serve the static files.
:param uri: URL path to be used for serving static content
:param file_or_directory: Path for the Static file/directory with
static files
:param pattern: Regex Pattern identifying the valid static files
:param use_modified_since: If true, send file modified time, and return
not modified if the browser's matches the server's
:param use_content_range: If true, process header for range requests
and sends the file part that is requested
:param stream_large_files: If true, use the
:func:`StreamingHTTPResponse.file_stream` handler rather
than the :func:`HTTPResponse.file` handler to send the file.
If this is an integer, this represents the threshold size to
switch to :func:`StreamingHTTPResponse.file_stream`
:param name: user defined name used for url_for
:param host: Host IP or FQDN for the service to use
:param strict_slashes: Instruct :class:`Sanic` to check if the request
URLs need to terminate with a */*
:param content_type: user defined content type for header
:param apply: If true, will register the route immediately
:param resource_type: Explicitly declare a resource to be a "
file" or a "dir"
:param index: When exposing against a directory, index is the name that
will be served as the default file. When multiple files names are
passed, then they will be tried in order.
:param directory_view: Whether to fallback to showing the directory
viewer when exposing a directory
:param directory_handler: An instance of :class:`DirectoryHandler`
that can be used for explicitly controlling and subclassing the
behavior of the default directory handler
:return: routes registered on the router
:rtype: List[sanic.router.Route]
"""
name = self._generate_name(name)
if strict_slashes is None and self.strict_slashes is not None:
strict_slashes = self.strict_slashes
if not isinstance(file_or_directory, (str, bytes, PurePath)):
raise ValueError(
f"Static route must be a valid path, not {file_or_directory}"
)
try:
file_or_directory = Path(file_or_directory)
except TypeError:
raise TypeError(
"Static file or directory must be a path-like object or string"
)
if directory_handler and (directory_view or index):
raise ValueError(
"When explicitly setting directory_handler, you cannot "
"set either directory_view or index. Instead, pass "
"these arguments to your DirectoryHandler instance."
)
if not directory_handler:
directory_handler = DirectoryHandler(
uri=uri,
directory=file_or_directory,
directory_view=directory_view,
index=index,
)
static = FutureStatic(
uri,
file_or_directory,
pattern,
use_modified_since,
use_content_range,
stream_large_files,
name,
host,
strict_slashes,
content_type,
resource_type,
directory_handler,
)
self._future_statics.add(static)
if apply:
self._apply_static(static)
class StaticHandleMixin(metaclass=SanicMeta):
def _apply_static(self, static: FutureStatic) -> Route:
return self._register_static(static)
def _register_static(
self,
static: FutureStatic,
):
# TODO: Though sanic is not a file server, I feel like we should
# at least make a good effort here. Modified-since is nice, but
# we could also look into etags, expires, and caching
"""
Register a static directory handler with Sanic by adding a route to the
router and registering a handler.
"""
if isinstance(static.file_or_directory, bytes):
file_or_directory = static.file_or_directory.decode("utf-8")
elif isinstance(static.file_or_directory, PurePath):
file_or_directory = str(static.file_or_directory)
elif not isinstance(static.file_or_directory, str):
raise ValueError("Invalid file path string.")
else:
file_or_directory = static.file_or_directory
uri = static.uri
name = static.name
# If we're not trying to match a file directly,
# serve from the folder
if not static.resource_type:
if not path.isfile(file_or_directory):
uri = uri.rstrip("/")
uri += "/<__file_uri__:path>"
elif static.resource_type == "dir":
if path.isfile(file_or_directory):
raise TypeError(
"Resource type improperly identified as directory. "
f"'{file_or_directory}'"
)
uri = uri.rstrip("/")
uri += "/<__file_uri__:path>"
elif static.resource_type == "file" and not path.isfile(
file_or_directory
):
raise TypeError(
"Resource type improperly identified as file. "
f"'{file_or_directory}'"
)
elif static.resource_type != "file":
raise ValueError(
"The resource_type should be set to 'file' or 'dir'"
)
# special prefix for static files
# if not static.name.startswith("_static_"):
# name = f"_static_{static.name}"
_handler = wraps(self._static_request_handler)(
partial(
self._static_request_handler,
file_or_directory=file_or_directory,
use_modified_since=static.use_modified_since,
use_content_range=static.use_content_range,
stream_large_files=static.stream_large_files,
content_type=static.content_type,
directory_handler=static.directory_handler,
)
)
route, _ = self.route( # type: ignore
uri=uri,
methods=["GET", "HEAD"],
name=name,
host=static.host,
strict_slashes=static.strict_slashes,
static=True,
)(_handler)
return route
async def _static_request_handler(
self,
request: Request,
*,
file_or_directory: PathLike,
use_modified_since: bool,
use_content_range: bool,
stream_large_files: Union[bool, int],
directory_handler: DirectoryHandler,
content_type: Optional[str] = None,
__file_uri__: Optional[str] = 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
# and it has not been modified since
stats = None
if use_modified_since:
stats = await stat_async(file_path)
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
)
_range = None
if use_content_range:
_range = None
if not stats:
stats = await stat_async(file_path)
headers["Accept-Ranges"] = "bytes"
headers["Content-Length"] = str(stats.st_size)
if request.method != "HEAD":
try:
_range = ContentRangeHandler(request, stats)
except HeaderNotFound:
pass
else:
del headers["Content-Length"]
headers.update(_range.headers)
if "content-type" not in headers:
content_type = (
content_type
or guess_type(file_path)[0]
or DEFAULT_HTTP_CONTENT_TYPE
)
if "charset=" not in content_type and (
content_type.startswith("text/")
or content_type == "application/javascript"
):
content_type += "; charset=utf-8"
headers["Content-Type"] = content_type
if request.method == "HEAD":
return HTTPResponse(headers=headers)
else:
if stream_large_files:
if isinstance(stream_large_files, bool):
threshold = 1024 * 1024
else:
threshold = stream_large_files
if not stats:
stats = await stat_async(file_path)
if stats.st_size >= threshold:
return await file_stream(
file_path, headers=headers, _range=_range
)
return await file(file_path, headers=headers, _range=_range)
except (IsADirectoryError, PermissionError):
return await directory_handler.handle(request, request.path)
except RangeNotSatisfiable:
raise
except FileNotFoundError:
raise not_found
except Exception:
error_logger.exception(
"Exception in static request handler: "
f"path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise
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()
if __file_uri__:
# Strip all / that in the beginning of the URL to help prevent
# python from herping a derp and treating the uri as an
# absolute path
unquoted_file_uri = unquote(__file_uri__).lstrip("/")
file_path_raw = Path(file_or_directory, unquoted_file_uri)
file_path = file_path_raw.resolve()
if (
file_path < root_path and not file_path_raw.is_symlink()
) or ".." in file_path_raw.parts:
error_logger.exception(
f"File not found: path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise not_found
try:
file_path.relative_to(root_path)
except ValueError:
if not file_path_raw.is_symlink():
error_logger.exception(
f"File not found: path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise not_found
return file_path

View File

@@ -1,7 +1,6 @@
from pathlib import Path
from pathlib import PurePath
from typing import Dict, Iterable, List, NamedTuple, Optional, Union
from sanic.handlers.directory import DirectoryHandler
from sanic.models.handler_types import (
ErrorMiddlewareType,
ListenerType,
@@ -47,17 +46,16 @@ class FutureException(NamedTuple):
class FutureStatic(NamedTuple):
uri: str
file_or_directory: Path
file_or_directory: Union[str, bytes, PurePath]
pattern: str
use_modified_since: bool
use_content_range: bool
stream_large_files: Union[bool, int]
stream_large_files: bool
name: str
host: Optional[str]
strict_slashes: Optional[bool]
content_type: Optional[str]
content_type: Optional[bool]
resource_type: Optional[str]
directory_handler: DirectoryHandler
class FutureSignal(NamedTuple):

View File

@@ -1,70 +0,0 @@
from abc import ABC, abstractmethod
from html5tagger import HTML, Builder, Document
from sanic import __version__ as VERSION
from sanic.application.logo import SVG_LOGO_SIMPLE
from sanic.pages.css import CSS
class BasePage(ABC, metaclass=CSS): # no cov
TITLE = "Sanic"
HEADING = None
CSS: str
doc: Builder
def __init__(self, debug: bool = True) -> None:
self.debug = debug
@property
def style(self) -> str:
return self.CSS
def render(self) -> str:
self.doc = Document(self.TITLE, lang="en", id="sanic")
self._head()
self._body()
self._foot()
return str(self.doc)
def _head(self) -> None:
self.doc.style(HTML(self.style))
with self.doc.header:
self.doc.div(self.HEADING or self.TITLE)
def _foot(self) -> None:
with self.doc.footer:
self.doc.div("powered by")
with self.doc.div:
self._sanic_logo()
if self.debug:
self.doc.div(f"Version {VERSION}")
with self.doc.div:
for idx, (title, href) in enumerate(
(
("Docs", "https://sanic.dev"),
("Help", "https://sanic.dev/en/help.html"),
("GitHub", "https://github.com/sanic-org/sanic"),
)
):
if idx > 0:
self.doc(" | ")
self.doc.a(
title,
href=href,
target="_blank",
referrerpolicy="no-referrer",
)
self.doc.div("DEBUG mode")
@abstractmethod
def _body(self) -> None:
...
def _sanic_logo(self) -> None:
self.doc.a(
HTML(SVG_LOGO_SIMPLE),
href="https://sanic.dev",
target="_blank",
referrerpolicy="no-referrer",
)

View File

@@ -1,35 +0,0 @@
from abc import ABCMeta
from pathlib import Path
from typing import Optional
CURRENT_DIR = Path(__file__).parent
def _extract_style(maybe_style: Optional[str], name: str) -> str:
if maybe_style is not None:
maybe_path = Path(maybe_style)
if maybe_path.exists():
return maybe_path.read_text(encoding="UTF-8")
return maybe_style
maybe_path = CURRENT_DIR / "styles" / f"{name}.css"
if maybe_path.exists():
return maybe_path.read_text(encoding="UTF-8")
return ""
class CSS(ABCMeta):
"""Cascade stylesheets, i.e. combine all ancestor styles"""
def __new__(cls, name, bases, attrs):
Page = super().__new__(cls, name, bases, attrs)
# Use a locally defined STYLE or the one from styles directory
Page.STYLE = _extract_style(attrs.get("STYLE_FILE"), name)
Page.STYLE += attrs.get("STYLE_APPEND", "")
# Combine with all ancestor styles
Page.CSS = "".join(
Class.STYLE
for Class in reversed(Page.__mro__)
if type(Class) is CSS
)
return Page

View File

@@ -1,66 +0,0 @@
import sys
from typing import Dict, Iterable
from html5tagger import E
from .base import BasePage
if sys.version_info < (3, 8): # no cov
FileInfo = Dict
else:
from typing import TypedDict
class FileInfo(TypedDict):
icon: str
file_name: str
file_access: str
file_size: str
class DirectoryPage(BasePage): # no cov
TITLE = "Directory Viewer"
def __init__(
self, files: Iterable[FileInfo], url: str, debug: bool
) -> None:
super().__init__(debug)
self.files = files
self.url = url
def _body(self) -> None:
with self.doc.main:
self._headline()
files = list(self.files)
if files:
self._file_table(files)
else:
self.doc.p("The folder is empty.")
def _headline(self):
"""Implement a heading with the current path, combined with
breadcrumb links"""
with self.doc.h1(id="breadcrumbs"):
p = self.url.split("/")[:-1]
for i, part in enumerate(p):
path = "/".join(p[: i + 1]) + "/"
with self.doc.a(href=path):
self.doc.span(part, class_="dir").span("/", class_="sep")
def _file_table(self, files: Iterable[FileInfo]):
with self.doc.table(class_="autoindex container"):
for f in files:
self._file_row(**f)
def _file_row(
self,
icon: str,
file_name: str,
file_access: str,
file_size: str,
):
first = E.span(icon, class_="icon").a(file_name, href=file_name)
self.doc.tr.td(first).td(file_size).td(file_access)

View File

@@ -1,109 +0,0 @@
from typing import Any, Mapping
import tracerite.html
from html5tagger import E
from tracerite import html_traceback, inspector
from sanic.request import Request
from .base import BasePage
# Avoid showing the request in the traceback variable inspectors
inspector.blacklist_types += (Request,)
ENDUSER_TEXT = """\
We're sorry, but it looks like something went wrong. Please try refreshing \
the page or navigating back to the homepage. If the issue persists, our \
technical team is working to resolve it as soon as possible. We apologize \
for the inconvenience and appreciate your patience.\
"""
class ErrorPage(BasePage):
STYLE_APPEND = tracerite.html.style
def __init__(
self,
debug: bool,
title: str,
text: str,
request: Request,
exc: Exception,
) -> None:
super().__init__(debug)
name = request.app.name.replace("_", " ").strip()
if name.islower():
name = name.title()
self.TITLE = f"Application {name} cannot handle your request"
self.HEADING = E("Application ").strong(name)(
" cannot handle your request"
)
self.title = title
self.text = text
self.request = request
self.exc = exc
self.details_open = not getattr(exc, "quiet", False)
def _head(self) -> None:
self.doc._script(tracerite.html.javascript)
super()._head()
def _body(self) -> None:
debug = self.request.app.debug
route_name = self.request.name or "[route not found]"
with self.doc.main:
self.doc.h1(f"⚠️ {self.title}").p(self.text)
# Show context details if available on the exception
context = getattr(self.exc, "context", None)
if context:
self._key_value_table(
"Issue context", "exception-context", context
)
if not debug:
with self.doc.div(id="enduser"):
self.doc.p(ENDUSER_TEXT).p.a("Front Page", href="/")
return
# Show additional details in debug mode,
# open by default for 500 errors
with self.doc.details(open=self.details_open, class_="smalltext"):
# Show extra details if available on the exception
extra = getattr(self.exc, "extra", None)
if extra:
self._key_value_table(
"Issue extra data", "exception-extra", extra
)
self.doc.summary(
"Details for developers (Sanic debug mode only)"
)
if self.exc:
with self.doc.div(class_="exception-wrapper"):
self.doc.h2(f"Exception in {route_name}:")
self.doc(
html_traceback(self.exc, include_js_css=False)
)
self._key_value_table(
f"{self.request.method} {self.request.path}",
"request-headers",
self.request.headers,
)
def _key_value_table(
self, title: str, table_id: str, data: Mapping[str, Any]
) -> None:
with self.doc.div(class_="key-value-display"):
self.doc.h2(title)
with self.doc.dl(id=table_id, class_="key-value-table smalltext"):
for key, value in data.items():
# Reading values may cause a new exception, so suppress it
try:
value = str(value)
except Exception:
value = E.em("Unable to display value")
self.doc.dt.span(key, class_="nobr key").span(": ").dd(
value
)

View File

@@ -1,146 +0,0 @@
/** BasePage **/
:root {
--sanic: #ff0d68;
--sanic-yellow: #FFE900;
--sanic-background: #efeced;
--sanic-text: #121010;
--sanic-text-lighter: #756169;
--sanic-link: #ff0d68;
--sanic-block-background: #f7f4f6;
--sanic-block-text: #000;
--sanic-block-alt-text: #6b6468;
--sanic-header-background: #272325;
--sanic-header-border: #fff;
--sanic-header-text: #fff;
--sanic-highlight-background: var(--sanic-yellow);
--sanic-highlight-text: var(--sanic-text);
--sanic-tab-background: #f7f4f6;
--sanic-tab-shadow: #f7f6f6;
--sanic-tab-text: #222021;
--sanic-tracerite-var: var(--sanic-text);
--sanic-tracerite-val: #ff0d68;
--sanic-tracerite-type: #6d6a6b;
}
@media (prefers-color-scheme: dark) {
:root {
--sanic-text: #f7f4f6;
--sanic-background: #121010;
--sanic-block-background: #0f0d0e;
--sanic-block-text: #f7f4f6;
--sanic-header-background: #030203;
--sanic-header-border: #000;
--sanic-highlight-text: var(--sanic-background);
--sanic-tab-background: #292728;
--sanic-tab-shadow: #0f0d0e;
--sanic-tab-text: #aea7ab;
}
}
html {
font: 16px sans-serif;
background: var(--sanic-background);
color: var(--sanic-text);
scrollbar-gutter: stable;
overflow: hidden auto;
}
body {
margin: 0;
font-size: 1.25rem;
line-height: 125%;
}
body>* {
padding: 1rem 2vw;
}
@media (max-width: 1000px) {
body>* {
padding: 0.5rem 1.5vw;
}
html {
/* Scale everything by rem of 6px-16px by viewport width */
font-size: calc(6px + 10 * 100vw / 1000);
}
}
main {
/* Make sure the footer is closer to bottom */
min-height: 70vh;
/* Generous padding for readability */
padding: 1rem 2.5rem;
}
.smalltext {
font-size: 1.0rem;
}
.container {
min-width: 600px;
max-width: 1600px;
}
header {
background: var(--sanic-header-background);
color: var(--sanic-header-text);
border-bottom: 1px solid var(--sanic-header-border);
text-align: center;
}
footer {
text-align: center;
display: flex;
flex-direction: column;
font-size: 0.8rem;
margin: 2rem;
line-height: 1.5em;
}
h1 {
text-align: left;
}
a {
text-decoration: none;
color: var(--sanic-link);
}
a:hover,
a:focus {
text-decoration: underline;
outline: none;
}
span.icon {
margin-right: 1rem;
}
#logo-simple {
height: 1.75rem;
padding: 0 0.25rem;
}
@media (prefers-color-scheme: dark) {
#logo-simple path:last-child {
fill: #e1e1e1;
}
}
#sanic pre,
#sanic code {
font-family: "Fira Code",
"Source Code Pro",
Menlo,
Meslo,
Monaco,
Consolas,
Lucida Console,
monospace;
font-size: 0.8rem;
}

View File

@@ -1,63 +0,0 @@
/** DirectoryPage **/
#breadcrumbs>a:hover {
text-decoration: none;
}
#breadcrumbs>a .dir {
padding: 0 0.25em;
}
#breadcrumbs>a:first-child:hover::before,
#breadcrumbs>a .dir:hover {
text-decoration: underline;
}
#breadcrumbs>a:first-child::before {
content: "🏠";
}
#breadcrumbs>a:last-child {
color: #ff0d68;
}
main a {
color: inherit;
font-weight: bold;
}
table.autoindex {
width: 100%;
font-family: monospace;
font-size: 1.25rem;
}
table.autoindex tr {
display: flex;
}
table.autoindex tr:hover {
background-color: #ddd;
}
table.autoindex td {
margin: 0 0.5rem;
}
table.autoindex td:first-child {
flex: 1;
}
table.autoindex td:nth-child(2) {
text-align: right;
}
table.autoindex td:last-child {
text-align: right;
}
@media (prefers-color-scheme: dark) {
table.autoindex tr:hover {
background-color: #222;
}
}

View File

@@ -1,108 +0,0 @@
/** ErrorPage **/
#enduser {
max-width: 30em;
margin: 5em auto 5em auto;
text-align: justify;
/*text-justify: both;*/
}
#enduser a {
color: var(--sanic-blue);
}
#enduser p:last-child {
text-align: right;
}
summary {
margin-top: 3em;
color: var(--sanic-text-lighter);
cursor: pointer;
}
.tracerite {
--tracerite-var: var(--sanic-tracerite-var);
--tracerite-val: var(--sanic-tracerite-val);
--tracerite-type: var(--sanic-tracerite-type);
--tracerite-exception: var(--sanic);
--tracerite-highlight: var(--sanic-yellow);
--tracerite-tab: var(--sanic-tab-background);
--tracerite-tab-text: var(--sanic-tab-text);
}
.tracerite>h3 {
margin: 0.5rem 0 !important;
}
#sanic .tracerite .traceback-labels button {
font-size: 0.8rem;
line-height: 120%;
background: var(--tracerite-tab);
color: var(--tracerite-tab-text);
transition: 0.3s;
cursor: pointer;
}
.tracerite .traceback-labels {
padding-top: 5px;
}
.tracerite .traceback-labels button:hover {
filter: contrast(150%) brightness(120%) drop-shadow(0 -0 2px var(--sanic-tab-shadow));
}
#sanic .tracerite .tracerite-tooltip::before {
bottom: 1.75em;
}
#sanic .tracerite .traceback-details mark span {
background: var(--sanic-highlight-background);
color: var(--sanic-highlight-text);
}
header {
background: var(--sanic-header-background);
}
h2 {
font-size: 1.3rem;
color: var(--sanic-text);
}
.key-value-display,
.exception-wrapper {
padding: 0.5rem;
margin-top: 1rem;
}
.key-value-display {
background-color: var(--sanic-block-background);
color: var(--sanic-block-text);
}
.key-value-display h2 {
margin-bottom: 0.2em;
}
dl.key-value-table {
width: 100%;
margin: 0;
display: grid;
grid-template-columns: 1fr 5fr;
grid-gap: .3em;
white-space: pre-wrap;
}
dl.key-value-table * {
margin: 0;
}
dl.key-value-table dt {
color: var(--sanic-block-alt-text);
word-break: break-word;
}
dl.key-value-table dd {
/* Better breaking for cookies header and such */
word-break: break-all;
}

View File

@@ -8,10 +8,10 @@ from typing import (
DefaultDict,
Dict,
List,
NamedTuple,
Optional,
Tuple,
Union,
cast,
)
from sanic_routing.route import Route
@@ -26,11 +26,14 @@ if TYPE_CHECKING:
from sanic.server import ConnInfo
from sanic.app import Sanic
import email.utils
import unicodedata
import uuid
from collections import defaultdict
from http.cookies import SimpleCookie
from types import SimpleNamespace
from urllib.parse import parse_qs, parse_qsl, urlunparse
from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse
from httptools import parse_url
from httptools.parser.errors import HttpParserInvalidURLError
@@ -42,10 +45,9 @@ from sanic.constants import (
IDEMPOTENT_HTTP_METHODS,
SAFE_HTTP_METHODS,
)
from sanic.cookies.request import CookieRequestParameters, parse_cookie
from sanic.exceptions import BadRequest, BadURL, ServerError
from sanic.headers import (
AcceptList,
AcceptContainer,
Options,
parse_accept,
parse_content_header,
@@ -55,13 +57,10 @@ from sanic.headers import (
parse_xforwarded,
)
from sanic.http import Stage
from sanic.log import error_logger
from sanic.log import deprecation, error_logger, logger
from sanic.models.protocol_types import TransportProtocol
from sanic.response import BaseHTTPResponse, HTTPResponse
from .form import parse_multipart_form
from .parameters import RequestParameters
try:
from ujson import loads as json_loads # type: ignore
@@ -69,6 +68,25 @@ except ImportError:
from json import loads as json_loads # type: ignore
class RequestParameters(dict):
"""
Hosts a dict with lists as values where get returns the first
value of the list and getlist returns the whole shebang
"""
def get(self, name: str, default: Optional[Any] = None) -> Optional[Any]:
"""Return the first value, either the default or actual"""
return super().get(name, [default])[0]
def getlist(
self, name: str, default: Optional[Any] = None
) -> Optional[Any]:
"""
Return the entire list
"""
return super().get(name, default)
class Request:
"""
Properties of an HTTP request such as URL, headers, etc.
@@ -102,7 +120,6 @@ class Request:
"method",
"parsed_accept",
"parsed_args",
"parsed_cookies",
"parsed_credentials",
"parsed_files",
"parsed_form",
@@ -133,8 +150,7 @@ class Request:
try:
self._parsed_url = parse_url(url_bytes)
except HttpParserInvalidURLError:
url = url_bytes.decode(errors="backslashreplace")
raise BadURL(f"Bad URL: {url}")
raise BadURL(f"Bad URL: {url_bytes.decode()}")
self._id: Optional[Union[uuid.UUID, str, int]] = None
self._name: Optional[str] = None
self._stream_id = stream_id
@@ -150,25 +166,25 @@ class Request:
self.body = b""
self.conn_info: Optional[ConnInfo] = None
self.ctx = SimpleNamespace()
self.parsed_accept: Optional[AcceptList] = None
self.parsed_forwarded: Optional[Options] = None
self.parsed_accept: Optional[AcceptContainer] = None
self.parsed_credentials: Optional[Credentials] = None
self.parsed_json = None
self.parsed_form: Optional[RequestParameters] = None
self.parsed_files: Optional[RequestParameters] = None
self.parsed_token: Optional[str] = None
self.parsed_args: DefaultDict[
Tuple[bool, bool, str, str], RequestParameters
] = defaultdict(RequestParameters)
self.parsed_cookies: Optional[RequestParameters] = None
self.parsed_credentials: Optional[Credentials] = None
self.parsed_files: Optional[RequestParameters] = None
self.parsed_form: Optional[RequestParameters] = None
self.parsed_forwarded: Optional[Options] = None
self.parsed_json = None
self.parsed_not_grouped_args: DefaultDict[
Tuple[bool, bool, str, str], List[Tuple[str, str]]
] = defaultdict(list)
self.parsed_token: Optional[str] = None
self._request_middleware_started = False
self._response_middleware_started = False
self.responded: bool = False
self.route: Optional[Route] = None
self.stream: Optional[Stream] = None
self._cookies: Optional[Dict[str, str]] = None
self._match_info: Dict[str, Any] = {}
self._protocol = None
@@ -205,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):
"""
@@ -473,17 +499,14 @@ class Request:
return self.parsed_json
@property
def accept(self) -> AcceptList:
"""Accepted response content types.
A convenience handler for easier RFC-compliant matching of MIME types,
parsed as a list that can match wildcards and includes */* by default.
def accept(self) -> AcceptContainer:
"""
:return: The ``Accept`` header parsed
:rtype: AcceptList
:rtype: AcceptContainer
"""
if self.parsed_accept is None:
self.parsed_accept = parse_accept(self.headers.get("accept"))
accept_header = self.headers.getone("accept", "")
self.parsed_accept = parse_accept(accept_header)
return self.parsed_accept
@property
@@ -705,21 +728,24 @@ class Request:
default values.
"""
def get_cookies(self) -> RequestParameters:
cookie = self.headers.getone("cookie", "")
self.parsed_cookies = CookieRequestParameters(parse_cookie(cookie))
return self.parsed_cookies
@property
def cookies(self) -> RequestParameters:
def cookies(self) -> Dict[str, str]:
"""
:return: Incoming cookies on the request
:rtype: Dict[str, str]
"""
if self.parsed_cookies is None:
self.get_cookies()
return cast(CookieRequestParameters, self.parsed_cookies)
if self._cookies is None:
cookie = self.headers.getone("cookie", None)
if cookie is not None:
cookies: SimpleCookie = SimpleCookie()
cookies.load(cookie)
self._cookies = {
name: cookie.value for name, cookie in cookies.items()
}
else:
self._cookies = {}
return self._cookies
@property
def content_type(self) -> str:
@@ -997,3 +1023,101 @@ class Request:
:rtype: bool
"""
return self.method in CACHEABLE_HTTP_METHODS
class File(NamedTuple):
"""
Model for defining a file. It is a ``namedtuple``, therefore you can
iterate over the object, or access the parameters by name.
:param type: The mimetype, defaults to text/plain
:param body: Bytes of the file
:param name: The filename
"""
type: str
body: bytes
name: str
def parse_multipart_form(body, boundary):
"""
Parse a request body and returns fields and files
:param body: bytes request body
:param boundary: bytes multipart boundary
:return: fields (RequestParameters), files (RequestParameters)
"""
files = RequestParameters()
fields = RequestParameters()
form_parts = body.split(boundary)
for form_part in form_parts[1:-1]:
file_name = None
content_type = "text/plain"
content_charset = "utf-8"
field_name = None
line_index = 2
line_end_index = 0
while not line_end_index == -1:
line_end_index = form_part.find(b"\r\n", line_index)
form_line = form_part[line_index:line_end_index].decode("utf-8")
line_index = line_end_index + 2
if not form_line:
break
colon_index = form_line.index(":")
idx = colon_index + 2
form_header_field = form_line[0:colon_index].lower()
form_header_value, form_parameters = parse_content_header(
form_line[idx:]
)
if form_header_field == "content-disposition":
field_name = form_parameters.get("name")
file_name = form_parameters.get("filename")
# non-ASCII filenames in RFC2231, "filename*" format
if file_name is None and form_parameters.get("filename*"):
encoding, _, value = email.utils.decode_rfc2231(
form_parameters["filename*"]
)
file_name = unquote(value, encoding=encoding)
# Normalize to NFC (Apple MacOS/iOS send NFD)
# Notes:
# - No effect for Windows, Linux or Android clients which
# already send NFC
# - Python open() is tricky (creates files in NFC no matter
# which form you use)
if file_name is not None:
file_name = unicodedata.normalize("NFC", file_name)
elif form_header_field == "content-type":
content_type = form_header_value
content_charset = form_parameters.get("charset", "utf-8")
if field_name:
post_data = form_part[line_index:-4]
if file_name is None:
value = post_data.decode(content_charset)
if field_name in fields:
fields[field_name].append(value)
else:
fields[field_name] = [value]
else:
form_file = File(
type=content_type, name=file_name, body=post_data
)
if field_name in files:
files[field_name].append(form_file)
else:
files[field_name] = [form_file]
else:
logger.debug(
"Form-data field does not have a 'name' parameter "
"in the Content-Disposition header"
)
return fields, files

View File

@@ -1,11 +0,0 @@
from .form import File, parse_multipart_form
from .parameters import RequestParameters
from .types import Request
__all__ = (
"File",
"parse_multipart_form",
"Request",
"RequestParameters",
)

View File

@@ -1,110 +0,0 @@
from __future__ import annotations
import email.utils
import unicodedata
from typing import NamedTuple
from urllib.parse import unquote
from sanic.headers import parse_content_header
from sanic.log import logger
from .parameters import RequestParameters
class File(NamedTuple):
"""
Model for defining a file. It is a ``namedtuple``, therefore you can
iterate over the object, or access the parameters by name.
:param type: The mimetype, defaults to text/plain
:param body: Bytes of the file
:param name: The filename
"""
type: str
body: bytes
name: str
def parse_multipart_form(body, boundary):
"""
Parse a request body and returns fields and files
:param body: bytes request body
:param boundary: bytes multipart boundary
:return: fields (RequestParameters), files (RequestParameters)
"""
files = {}
fields = {}
form_parts = body.split(boundary)
for form_part in form_parts[1:-1]:
file_name = None
content_type = "text/plain"
content_charset = "utf-8"
field_name = None
line_index = 2
line_end_index = 0
while not line_end_index == -1:
line_end_index = form_part.find(b"\r\n", line_index)
form_line = form_part[line_index:line_end_index].decode("utf-8")
line_index = line_end_index + 2
if not form_line:
break
colon_index = form_line.index(":")
idx = colon_index + 2
form_header_field = form_line[0:colon_index].lower()
form_header_value, form_parameters = parse_content_header(
form_line[idx:]
)
if form_header_field == "content-disposition":
field_name = form_parameters.get("name")
file_name = form_parameters.get("filename")
# non-ASCII filenames in RFC2231, "filename*" format
if file_name is None and form_parameters.get("filename*"):
encoding, _, value = email.utils.decode_rfc2231(
form_parameters["filename*"]
)
file_name = unquote(value, encoding=encoding)
# Normalize to NFC (Apple MacOS/iOS send NFD)
# Notes:
# - No effect for Windows, Linux or Android clients which
# already send NFC
# - Python open() is tricky (creates files in NFC no matter
# which form you use)
if file_name is not None:
file_name = unicodedata.normalize("NFC", file_name)
elif form_header_field == "content-type":
content_type = form_header_value
content_charset = form_parameters.get("charset", "utf-8")
if field_name:
post_data = form_part[line_index:-4]
if file_name is None:
value = post_data.decode(content_charset)
if field_name in fields:
fields[field_name].append(value)
else:
fields[field_name] = [value]
else:
form_file = File(
type=content_type, name=file_name, body=post_data
)
if field_name in files:
files[field_name].append(form_file)
else:
files[field_name] = [form_file]
else:
logger.debug(
"Form-data field does not have a 'name' parameter "
"in the Content-Disposition header"
)
return RequestParameters(fields), RequestParameters(files)

View File

@@ -1,22 +0,0 @@
from __future__ import annotations
from typing import Any, Optional
class RequestParameters(dict):
"""
Hosts a dict with lists as values where get returns the first
value of the list and getlist returns the whole shebang
"""
def get(self, name: str, default: Optional[Any] = None) -> Optional[Any]:
"""Return the first value, either the default or actual"""
return super().get(name, [default])[0]
def getlist(
self, name: str, default: Optional[Any] = None
) -> Optional[Any]:
"""
Return the entire list
"""
return super().get(name, default)

View File

@@ -148,26 +148,7 @@ async def validate_file(
last_modified = datetime.fromtimestamp(
float(last_modified), tz=timezone.utc
).replace(microsecond=0)
if (
last_modified.utcoffset() is None
and if_modified_since.utcoffset() is not None
):
logger.warning(
"Cannot compare tz-aware and tz-naive datetimes. To avoid "
"this conflict Sanic is converting last_modified to UTC."
)
last_modified.replace(tzinfo=timezone.utc)
elif (
last_modified.utcoffset() is not None
and if_modified_since.utcoffset() is None
):
logger.warning(
"Cannot compare tz-aware and tz-naive datetimes. To avoid "
"this conflict Sanic is converting if_modified_since to UTC."
)
if_modified_since.replace(tzinfo=timezone.utc)
if last_modified.timestamp() <= if_modified_since.timestamp():
if last_modified <= if_modified_since:
return HTTPResponse(status=304)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from datetime import datetime
from functools import partial
from typing import (
TYPE_CHECKING,
@@ -18,7 +17,6 @@ from typing import (
from sanic.compat import Header
from sanic.cookies import CookieJar
from sanic.cookies.response import Cookie, SameSite
from sanic.exceptions import SanicException, ServerError
from sanic.helpers import (
Default,
@@ -160,117 +158,6 @@ class BaseHTTPResponse:
end_stream=end_stream or False,
)
def add_cookie(
self,
key: str,
value: str,
*,
path: str = "/",
domain: Optional[str] = None,
secure: bool = True,
max_age: Optional[int] = None,
expires: Optional[datetime] = None,
httponly: bool = False,
samesite: Optional[SameSite] = "Lax",
partitioned: bool = False,
comment: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> Cookie:
"""
Add a cookie to the CookieJar
:param key: Key of the cookie
:type key: str
:param value: Value of the cookie
:type value: str
:param path: Path of the cookie, defaults to None
:type path: Optional[str], optional
:param domain: Domain of the cookie, defaults to None
:type domain: Optional[str], optional
:param secure: Whether to set it as a secure cookie, defaults to True
:type secure: bool
:param max_age: Max age of the cookie in seconds; if set to 0 a
browser should delete it, defaults to None
:type max_age: Optional[int], optional
:param expires: When the cookie expires; if set to None browsers
should set it as a session cookie, defaults to None
:type expires: Optional[datetime], optional
:param httponly: Whether to set it as HTTP only, defaults to False
:type httponly: bool
:param samesite: How to set the samesite property, should be
strict, lax or none (case insensitive), defaults to Lax
:type samesite: Optional[SameSite], optional
:param partitioned: Whether to set it as partitioned, defaults to False
:type partitioned: bool
:param comment: A cookie comment, defaults to None
:type comment: Optional[str], optional
:param host_prefix: Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True,
defaults to False
:type host_prefix: bool
:param secure_prefix: Whether to add __Secure- as a prefix to the key.
This requires that secure=True, defaults to False
:type secure_prefix: bool
:return: The instance of the created cookie
:rtype: Cookie
"""
return self.cookies.add_cookie(
key=key,
value=value,
path=path,
domain=domain,
secure=secure,
max_age=max_age,
expires=expires,
httponly=httponly,
samesite=samesite,
partitioned=partitioned,
comment=comment,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
def delete_cookie(
self,
key: str,
*,
path: str = "/",
domain: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> None:
"""
Delete a cookie
This will effectively set it as Max-Age: 0, which a browser should
interpret it to mean: "delete the cookie".
Since it is a browser/client implementation, your results may vary
depending upon which client is being used.
:param key: The key to be deleted
:type key: str
:param path: Path of the cookie, defaults to None
:type path: Optional[str], optional
:param domain: Domain of the cookie, defaults to None
:type domain: Optional[str], optional
:param host_prefix: Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True,
defaults to False
:type host_prefix: bool
:param secure_prefix: Whether to add __Secure- as a prefix to the key.
This requires that secure=True, defaults to False
:type secure_prefix: bool
"""
self.cookies.delete_cookie(
key=key,
path=path,
domain=domain,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
class HTTPResponse(BaseHTTPResponse):
"""
@@ -345,7 +232,7 @@ class JSONResponse(HTTPResponse):
body: Optional[Any] = None,
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
content_type: str = "application/json",
dumps: Optional[Callable[..., str]] = None,
**kwargs: Any,
):
@@ -520,8 +407,6 @@ class ResponseStream:
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
if not isinstance(headers, Header):
headers = Header(headers)
self.streaming_fn = streaming_fn
self.status = status
self.headers = headers or Header()

View File

@@ -39,15 +39,13 @@ class Router(BaseRouter):
extra={"host": host} if host else None,
)
except RoutingNotFound as e:
raise NotFound(f"Requested URL {e.path} not found") from None
raise NotFound("Requested URL {} not found".format(e.path))
except NoMethod as e:
raise MethodNotAllowed(
f"Method {method} not allowed for URL {path}",
"Method {} not allowed for URL {}".format(method, path),
method=method,
allowed_methods=tuple(e.allowed_methods)
if e.allowed_methods
else None,
) from None
allowed_methods=e.allowed_methods,
)
@lru_cache(maxsize=ROUTER_CACHE_SIZE)
def get( # type: ignore
@@ -63,7 +61,6 @@ class Router(BaseRouter):
correct response
:rtype: Tuple[ Route, RouteHandler, Dict[str, Any]]
"""
__tracebackhide__ = True
return self._get(path, method, host)
def add( # type: ignore
@@ -135,16 +132,7 @@ class Router(BaseRouter):
if host:
params.update({"requirements": {"host": host}})
ident = name
if len(hosts) > 1:
ident = (
f"{name}_{host.replace('.', '_')}"
if name
else "__unnamed__"
)
route = super().add(**params) # type: ignore
route.extra.ident = ident
route.extra.ignore_body = ignore_body
route.extra.stream = stream
route.extra.hosts = hosts

View File

@@ -2,7 +2,7 @@ from sanic.models.server_types import ConnInfo, Signal
from sanic.server.async_server import AsyncioServer
from sanic.server.loop import try_use_uvloop
from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.runners import serve
from sanic.server.runners import serve, serve_multiple, serve_single
__all__ = (
@@ -11,5 +11,7 @@ __all__ = (
"HttpProtocol",
"Signal",
"serve",
"serve_multiple",
"serve_single",
"try_use_uvloop",
)

123
sanic/server/legacy.py Normal file
View File

@@ -0,0 +1,123 @@
import itertools
import os
import signal
import subprocess
import sys
from time import sleep
def _iter_module_files():
"""This iterates over all relevant Python files.
It goes through all
loaded files from modules, all files in folders of already loaded modules
as well as all files reachable through a package.
"""
# The list call is necessary on Python 3 in case the module
# dictionary modifies during iteration.
for module in list(sys.modules.values()):
if module is None:
continue
filename = getattr(module, "__file__", None)
if filename:
old = None
while not os.path.isfile(filename):
old = filename
filename = os.path.dirname(filename)
if filename == old:
break
else:
if filename[-4:] in (".pyc", ".pyo"):
filename = filename[:-1]
yield filename
def _get_args_for_reloading():
"""Returns the executable."""
main_module = sys.modules["__main__"]
mod_spec = getattr(main_module, "__spec__", None)
if sys.argv[0] in ("", "-c"):
raise RuntimeError(
f"Autoreloader cannot work with argv[0]={sys.argv[0]!r}"
)
if mod_spec:
# Parent exe was launched as a module rather than a script
return [sys.executable, "-m", mod_spec.name] + sys.argv[1:]
return [sys.executable] + sys.argv
def restart_with_reloader(changed=None):
"""Create a new process and a subprocess in it with the same arguments as
this one.
"""
reloaded = ",".join(changed) if changed else ""
return subprocess.Popen( # nosec B603
_get_args_for_reloading(),
env={
**os.environ,
"SANIC_SERVER_RUNNING": "true",
"SANIC_RELOADER_PROCESS": "true",
"SANIC_RELOADED_FILES": reloaded,
},
)
def _check_file(filename, mtimes):
need_reload = False
mtime = os.stat(filename).st_mtime
old_time = mtimes.get(filename)
if old_time is None:
mtimes[filename] = mtime
elif mtime > old_time:
mtimes[filename] = mtime
need_reload = True
return need_reload
def watchdog(sleep_interval, reload_dirs):
"""Watch project files, restart worker process if a change happened.
:param sleep_interval: interval in second.
:return: Nothing
"""
def interrupt_self(*args):
raise KeyboardInterrupt
mtimes = {}
signal.signal(signal.SIGTERM, interrupt_self)
if os.name == "nt":
signal.signal(signal.SIGBREAK, interrupt_self)
worker_process = restart_with_reloader()
try:
while True:
changed = set()
for filename in itertools.chain(
_iter_module_files(),
*(d.glob("**/*") for d in reload_dirs),
):
try:
if _check_file(filename, mtimes):
path = (
filename
if isinstance(filename, str)
else filename.resolve()
)
changed.add(str(path))
except OSError:
continue
if changed:
worker_process.terminate()
worker_process.wait()
worker_process = restart_with_reloader(changed)
sleep(sleep_interval)
except KeyboardInterrupt:
pass
finally:
worker_process.terminate()
worker_process.wait()

View File

@@ -10,7 +10,7 @@ except ImportError: # websockets >= 11.0
from websockets.typing import Subprotocol
from sanic.exceptions import SanicException
from sanic.exceptions import ServerError
from sanic.log import logger
from sanic.server import HttpProtocol
@@ -123,7 +123,7 @@ class WebSocketProtocol(HttpProtocol):
"Failed to open a WebSocket connection.\n"
"See server log for more information.\n"
)
raise SanicException(msg, status_code=500)
raise ServerError(msg, status_code=500)
if 100 <= resp.status_code <= 299:
first_line = (
f"HTTP/1.1 {resp.status_code} {resp.reason_phrase}\r\n"
@@ -138,7 +138,7 @@ class WebSocketProtocol(HttpProtocol):
rbody += b"\r\n\r\n"
await super().send(rbody)
else:
raise SanicException(resp.body, resp.status_code)
raise ServerError(resp.body, resp.status_code)
self.websocket = WebsocketImplProtocol(
ws_proto,
ping_interval=self.websocket_ping_interval,

View File

@@ -9,17 +9,19 @@ from sanic.config import Config
from sanic.exceptions import ServerError
from sanic.http.constants import HTTP
from sanic.http.tls import get_ssl_context
from sanic.server.events import trigger_events
if TYPE_CHECKING:
from sanic.app import Sanic
import asyncio
import multiprocessing
import os
import socket
from functools import partial
from signal import SIG_IGN, SIGINT, SIGTERM
from signal import SIG_IGN, SIGINT, SIGTERM, Signals
from signal import signal as signal_func
from sanic.application.ext import setup_ext
@@ -29,7 +31,11 @@ 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
from sanic.server.socket import bind_unix_socket, remove_unix_socket
from sanic.server.socket import (
bind_socket,
bind_unix_socket,
remove_unix_socket,
)
try:
@@ -313,6 +319,94 @@ def _serve_http_3(
)
def serve_single(server_settings):
main_start = server_settings.pop("main_start", None)
main_stop = server_settings.pop("main_stop", None)
if not server_settings.get("run_async"):
# create new event_loop after fork
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server_settings["loop"] = loop
trigger_events(main_start, server_settings["loop"])
serve(**server_settings)
trigger_events(main_stop, server_settings["loop"])
server_settings["loop"].close()
def serve_multiple(server_settings, workers):
"""Start multiple server processes simultaneously. Stop on interrupt
and terminate signals, and drain connections when complete.
:param server_settings: kw arguments to be passed to the serve function
:param workers: number of workers to launch
:param stop_event: if provided, is used as a stop signal
:return:
"""
server_settings["reuse_port"] = True
server_settings["run_multiple"] = True
main_start = server_settings.pop("main_start", None)
main_stop = server_settings.pop("main_stop", None)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
trigger_events(main_start, loop)
# Create a listening socket or use the one in settings
sock = server_settings.get("sock")
unix = server_settings["unix"]
backlog = server_settings["backlog"]
if unix:
sock = bind_unix_socket(unix, backlog=backlog)
server_settings["unix"] = unix
if sock is None:
sock = bind_socket(
server_settings["host"], server_settings["port"], backlog=backlog
)
sock.set_inheritable(True)
server_settings["sock"] = sock
server_settings["host"] = None
server_settings["port"] = None
processes = []
def sig_handler(signal, frame):
server_logger.info(
"Received signal %s. Shutting down.", Signals(signal).name
)
for process in processes:
os.kill(process.pid, SIGTERM)
signal_func(SIGINT, lambda s, f: sig_handler(s, f))
signal_func(SIGTERM, lambda s, f: sig_handler(s, f))
mp = multiprocessing.get_context("fork")
for _ in range(workers):
process = mp.Process(
target=serve,
kwargs=server_settings,
)
process.daemon = True
process.start()
processes.append(process)
for process in processes:
process.join()
# the above processes will block this until they're stopped
for process in processes:
process.terminate()
trigger_events(main_stop, loop)
sock.close()
loop.close()
remove_unix_socket(unix)
def _build_protocol_kwargs(
protocol: Type[asyncio.Protocol], config: Config
) -> Dict[str, Union[int, float]]:

View File

@@ -45,7 +45,7 @@ class WebSocketConnection:
await self._send(message)
async def recv(self, *args, **kwargs) -> Optional[Union[str, bytes]]:
async def recv(self, *args, **kwargs) -> Optional[str]:
message = await self._receive()
if message["type"] == "websocket.receive":
@@ -53,7 +53,7 @@ class WebSocketConnection:
return message["text"]
except KeyError:
try:
return message["bytes"]
return message["bytes"].decode()
except KeyError:
raise InvalidUsage("Bad ASGI message received")
elif message["type"] == "websocket.disconnect":

View File

@@ -29,7 +29,7 @@ except ImportError: # websockets >= 11.0
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
@@ -99,6 +99,15 @@ class WebsocketImplProtocol:
def subprotocol(self):
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:
return False

View File

@@ -2,6 +2,7 @@ from pathlib import Path
from sanic import Sanic
from sanic.exceptions import SanicException
from sanic.response import redirect
def create_simple_server(directory: Path):
@@ -11,8 +12,10 @@ def create_simple_server(directory: Path):
)
app = Sanic("SimpleServer")
app.static(
"/", directory, name="main", directory_view=True, index="index.html"
)
app.static("/", directory, name="main")
@app.get("/")
def index(_):
return redirect(app.url_for("main", filename="index.html"))
return app

View File

@@ -3,11 +3,8 @@ from __future__ import annotations
import os
import sys
from contextlib import suppress
from importlib import import_module
from inspect import isfunction
from pathlib import Path
from ssl import SSLContext
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast
from sanic.http.tls.context import process_to_context
@@ -17,8 +14,6 @@ from sanic.http.tls.creators import MkcertCreator, TrustmeCreator
if TYPE_CHECKING:
from sanic import Sanic as SanicApp
DEFAULT_APP_NAME = "app"
class AppLoader:
def __init__(
@@ -40,11 +35,7 @@ class AppLoader:
if module_input:
delimiter = ":" if ":" in module_input else "."
if (
delimiter in module_input
and "\\" not in module_input
and "/" not in module_input
):
if module_input.count(delimiter):
module_name, app_name = module_input.rsplit(delimiter, 1)
self.module_name = module_name
self.app_name = app_name
@@ -63,30 +54,21 @@ class AppLoader:
from sanic.app import Sanic
from sanic.simple import create_simple_server
maybe_path = Path(self.module_input)
if self.as_simple or (
maybe_path.is_dir()
and ("\\" in self.module_input or "/" in self.module_input)
):
app = create_simple_server(maybe_path)
if self.as_simple:
path = Path(self.module_input)
app = create_simple_server(path)
else:
implied_app_name = False
if not self.module_name and not self.app_name:
self.module_name = self.module_input
self.app_name = DEFAULT_APP_NAME
implied_app_name = True
if self.module_name == "" and os.path.isdir(self.module_input):
raise ValueError(
"App not found.\n"
" Please use --simple if you are passing a "
"directory to sanic.\n"
f" eg. sanic {self.module_input} --simple"
)
module = import_module(self.module_name)
app = getattr(module, self.app_name, None)
if not app and implied_app_name:
raise ValueError(
"Looks like you only supplied a module name. Sanic "
"tried to locate an application instance named "
f"{self.module_name}:app, but was unable to locate "
"an application instance. Please provide a path "
"to a global instance of Sanic(), or a callable that "
"will return a Sanic() application instance."
)
if self.as_factory or isfunction(app):
if self.as_factory:
try:
app = app(self.args)
except TypeError:
@@ -97,18 +79,21 @@ class AppLoader:
if (
not isinstance(app, Sanic)
and self.args
and hasattr(self.args, "target")
and hasattr(self.args, "module")
):
with suppress(ModuleNotFoundError):
maybe_module = import_module(self.module_input)
app = getattr(maybe_module, "app", None)
if not app:
message = (
"Module is not a Sanic app, "
f"it is a {app_type_name}\n"
f" Perhaps you meant {self.args.target}:app?"
if callable(app):
solution = f"sanic {self.args.module} --factory"
raise ValueError(
"Module is not a Sanic app, it is a "
f"{app_type_name}\n"
" If this callable returns a "
f"Sanic instance try: \n{solution}"
)
raise ValueError(message)
raise ValueError(
f"Module is not a Sanic app, it is a {app_type_name}\n"
f" Perhaps you meant {self.args.module}:app?"
)
return app
@@ -118,16 +103,8 @@ class CertLoader:
"trustme": TrustmeCreator,
}
def __init__(
self,
ssl_data: Optional[
Union[SSLContext, Dict[str, Union[str, os.PathLike]]]
],
):
def __init__(self, ssl_data: Dict[str, Union[str, os.PathLike]]):
self._ssl_data = ssl_data
self._creator_class = None
if not ssl_data or not isinstance(ssl_data, dict):
return
creator_name = cast(str, ssl_data.get("creator"))

View File

@@ -5,7 +5,7 @@ from itertools import count
from random import choice
from signal import SIGINT, SIGTERM, Signals
from signal import signal as signal_func
from typing import Any, Callable, Dict, List, Optional
from typing import Dict, List, Optional
from sanic.compat import OS_IS_WINDOWS
from sanic.exceptions import ServerKilled
@@ -54,36 +54,9 @@ class WorkerManager:
signal_func(SIGINT, self.shutdown_signal)
signal_func(SIGTERM, self.shutdown_signal)
def manage(
self,
ident: str,
func: Callable[..., Any],
kwargs: Dict[str, Any],
transient: bool = False,
workers: int = 1,
) -> Worker:
"""
Instruct Sanic to manage a custom process.
:param ident: A name for the worker process
:type ident: str
:param func: The function to call in the background process
:type func: Callable[..., Any]
:param kwargs: Arguments to pass to the function
:type kwargs: Dict[str, Any]
:param transient: Whether to mark the process as transient. If True
then the Worker Manager will restart the process along
with any global restart (ex: auto-reload), defaults to False
:type transient: bool, optional
:param workers: The number of worker processes to run, defaults to 1
:type workers: int, optional
:return: The Worker instance
:rtype: Worker
"""
def manage(self, ident, func, kwargs, transient=False) -> Worker:
container = self.transient if transient else self.durable
worker = Worker(
ident, func, kwargs, self.context, self.worker_state, workers
)
worker = Worker(ident, func, kwargs, self.context, self.worker_state)
container[worker.ident] = worker
return worker
@@ -312,10 +285,6 @@ class WorkerManager:
def _sync_states(self):
for process in self.processes:
try:
state = self.worker_state[process.name].get("state")
except KeyError:
process.set_state(ProcessState.TERMINATED, True)
continue
state = self.worker_state[process.name].get("state")
if state and process.state.name != state:
process.set_state(ProcessState[state], True)

View File

@@ -192,17 +192,14 @@ class Worker:
server_settings,
context: BaseContext,
worker_state: Dict[str, Any],
num: int = 1,
):
self.ident = ident
self.num = num
self.context = context
self.serve = serve
self.server_settings = server_settings
self.worker_state = worker_state
self.processes: Set[WorkerProcess] = set()
for _ in range(num):
self.create_process()
self.create_process()
def create_process(self) -> WorkerProcess:
process = WorkerProcess(

View File

@@ -73,8 +73,8 @@ def worker_serve(
info.settings["app"] = a
a.state.server_info.append(info)
if isinstance(ssl, dict) or app.certloader_class is not CertLoader:
cert_loader = app.certloader_class(ssl or {})
if isinstance(ssl, dict):
cert_loader = CertLoader(ssl)
ssl = cert_loader.load(app)
for info in app.state.server_info:
info.settings["ssl"] = ssl

View File

@@ -35,7 +35,6 @@ def open_local(paths, mode="r", encoding="utf8"):
return codecs.open(path, mode, encoding)
def str_to_bool(val: str) -> bool:
val = val.lower()
if val in {
@@ -56,7 +55,6 @@ def str_to_bool(val: str) -> bool:
else:
raise ValueError(f"Invalid truth value {val}")
with open_local(["sanic", "__version__.py"], encoding="latin1") as fp:
try:
version = re.findall(
@@ -81,7 +79,7 @@ setup_kwargs = {
),
"long_description": long_description,
"packages": find_packages(exclude=("tests", "tests.*")),
"package_data": {"sanic": ["py.typed", "pages/styles/*"]},
"package_data": {"sanic": ["py.typed"]},
"platforms": "any",
"python_requires": ">=3.7",
"classifiers": [
@@ -111,12 +109,10 @@ requirements = [
"aiofiles>=0.6.0",
"websockets>=10.0",
"multidict>=5.0,<7.0",
"html5tagger>=1.2.1",
"tracerite>=1.0.0",
]
tests_require = [
"sanic-testing>=23.3.0",
"sanic-testing>=22.9.0",
"pytest==7.1.*",
"coverage",
"beautifulsoup4",

View File

@@ -1,19 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUa7OOlAGQfXOgUgRENJ9GbUgO7kwwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDMyMDA3MzE1M1oXDTIzMDQx
OTA3MzE1M1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAn2/RqVpzO7GFrgVGiowR5CzcFzf1tSFti1K/WIGr/jsu
NP+1R3sim17pgg6SCOFnUMRS0KnDihkzoeP6z+0tFsrbCH4V1+fq0iud8WgYQrgD
3ttUcHrz04p7wsMoeqndUQoLbyJzP8MpA2XJsoacdIVkuLv2AESGXLhJym/e9HGN
g8bqdz25X0hVTczZW1FN9AZyWWVf9Go6jqC7LCaOnYXAnOkEy2/JHdkeNXYFZHB3
71UemfkCjfp0vlRV8pVpkBGMhRNFphBTfxdqeWiGQwVqrhaJO4M7DJlQHCAPY16P
o9ywnhLDhFHD7KIfTih9XxrdgTowqcwyGX3e3aJpTwIDAQABo1MwUTAdBgNVHQ4E
FgQU5NogMq6mRBeGl4i6hIuUlcR2bVEwHwYDVR0jBBgwFoAU5NogMq6mRBeGl4i6
hIuUlcR2bVEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYW34
JY1kd0UO5HE41oxJD4PioQboXXX0al4RgKaUUsPykeHQbK0q0TSYAZLwRjooTVUO
Wvna5bU2mzyULqA2r/Cr/w4zb9xybO3SiHFHcU1RacouauHXROHwRm98i8A73xnH
vHws5BADr2ggnVcPNh4VOQ9ZvBlC7jhgpvMjqOEu5ZPCovhfZYfSsvBDHcD74ZYm
Di9DvqsJmrb23Dv3SUykm3W+Ql2q+JyjFj30rhD89CFwJ9iSlFwTYEwZLHA+mV6p
UKy3I3Fiht1Oc+nIivX5uhRSMbDVvDTVHbjjPujxxFjkiHXMjtwvwfg4Sb6du61q
AjBRFyXbNu4hZkkHOA==
-----END CERTIFICATE-----

View File

@@ -1,30 +0,0 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQI94UBqjaZlG4CAggA
MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBCvJhEy+3/+0Ec0gpd5dkP6BIIE
0E7rLplTe9rxK3sR9V0cx8Xn6V+uFhG3p7dzeMDCCKpGo9MEaacF5m+paGnBkMlH
Pz3rRoLA5jqzwXl4US/C5E1Or//2YBgF1XXKi3BPF/bVx/g6vR+xeobf9kQGbqQk
FNPYtP7mpg2dekp5BUsKSosIt8BkknWFvhBeNuGZT/zlMUuq1WpMe4KIh/W9IdNr
HolcuZJWBhQAwGPciWIZRyq48wKa++W7Jdg/aG8FviJQnjaAUv4CyZJHUJnaNwUx
iHOETpzIC+bhF2K+s4g5w68VCj6Jtz78sIBEZKzo7LI5QHdRHqYB5SJ/dGiV+h09
R/rQ/M+24mwHDlRSCxxq0yuDwUuGBlHyATeDCFeE3L5OX8yTLuqYJ6vUa6UbzMYA
8H4l5zfu9RrAhKYa9tD+4ONxMmHziIgmn5zvSXeBwJKfeUbnN4IKWLsSoSVspBRh
zLl51DMAnem4NEjLfIW8WYjhsvSYwd9BYqxXaAiv4Wjx9ZV1yLqFICC7tejpVdRT
afI0qMOfWu4ma6xVBg1ezLgF1wHIPrq6euTvWdnifYQopVICALlltEo5oxQ2i/OM
NY8RyovWujiGNsa3pId9HmZXiLyLXjKPstGWRK4liMyc2EiP099gTdBvrb+VQp+I
EyPavmh3WNhgZGOh3qah39X8HrBprc0PPfSPlxpaWdNMIIMSbcIWWdJEA/e4tcy/
uBaV4H3sNCtBApgrb6B9YUbS9CXNUburJo19T1sk2uCaO12qYfdu2IDEnFf8JiF3
i7nyftotRuoKq2D+V8d0PeMi/vJSo6+eZIn7VNe6ejYf+w0s7sxlpiKVzkslyOhq
n0T4M3ZkSwGIETzgkRRuTY1OK7slhglMgXlQ2FuIUUo6CRg9WjRJvI5rujLzLWfB
hkgP8STirjTV0DUWPFGtUcenvEcZPkYIQcoPHxOJGNW3ZPXNpt4RjbvPLeVzDm0O
WJiay/qhag/bXGqKraO3b6Y7FOzJa8kG4G0XrcFY1s2oCXRqRqYJAtwaEeVCjCSJ
Qy0OZkqcJEU7pv98pLMpG9OWz4Gle77g4KoQUJjQGtmg0MUMoPd0iPRmvkxsYg8E
Q9uZS3m6PpWmmYDY0Ik1w/4avs3skl2mW3dqcZGLEepkjiQSnFABsuvxKd+uIEQy
lyf9FrynXVcUI87LUkuniLRKwZZzFALVuc+BwtO3SA5mvEK22ZEq9QOysbwlpN54
G5xXJKJEeexUSjEUIij4J89RLsXldibhp7YYZ7rFviR6chIqC0V7G6VqAM9TOCrV
PWZXr3ZY5/pCZYs5DYKFJBFMSQ2UT/++VYxdZCeBH75vaxugbS8RdUM+iVDevWpQ
/AnP1FolNAgkVhi3Rw4L16SibkqpEzIi1svPWKMwXdvewA32UidLElhuTWWjI2Wm
veXhmEqwk/7ML4JMI7wHcDQdvSKen0mCL2J9tB7A/pewYyDE0ffIUmjxglOtw30f
ZOlQKhMaKJGXp00U2zsHA2NJRI/hThbJncsnZyvuLei0P42RrF+r64b/0gUH6IZ5
wPUttT815KSNoy+XXXum9YGDYYFoAL+6WVEkl6dgo+X0hcH7DDf5Nkewiq8UcJGh
/69vFIfp+JlpicXzZ+R42LO3T3luC907aFBywF3pmi//
-----END ENCRYPTED PRIVATE KEY-----

View File

@@ -1,7 +1,5 @@
import asyncio
import inspect
import logging
import os
import random
import re
import string
@@ -60,6 +58,7 @@ CACHE: Dict[str, Any] = {}
class RouteStringGenerator:
ROUTE_COUNT_PER_DEPTH = 100
HTTP_METHODS = HTTP_METHODS
ROUTE_PARAM_TYPES = ["str", "int", "float", "alpha", "uuid"]
@@ -233,12 +232,3 @@ def urlopen():
urlopen.read = Mock()
with patch("sanic.cli.inspector_client.urlopen", urlopen):
yield urlopen
@pytest.fixture(scope="module")
def static_file_directory():
"""The static directory to serve"""
current_file = inspect.getfile(inspect.currentframe())
current_directory = os.path.dirname(os.path.abspath(current_file))
static_directory = os.path.join(current_directory, "static")
return static_directory

View File

@@ -49,6 +49,6 @@ def create_app_with_args(args):
try:
logger.info(f"foo={args.foo}")
except AttributeError:
logger.info(f"target={args.target}")
logger.info(f"module={args.module}")
return app

View File

@@ -11,7 +11,7 @@ from aioquic.quic.events import ProtocolNegotiated
from sanic import Request, Sanic
from sanic.compat import Header
from sanic.config import DEFAULT_CONFIG
from sanic.exceptions import BadRequest, PayloadTooLarge
from sanic.exceptions import PayloadTooLarge
from sanic.http.constants import Stage
from sanic.http.http3 import Http3, HTTPReceiver
from sanic.models.server_types import ConnInfo
@@ -292,48 +292,3 @@ def test_request_conn_info(app):
receiver = http3.get_receiver_by_stream_id(1)
assert isinstance(receiver.request.conn_info, ConnInfo)
def test_request_header_encoding(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
with pytest.raises(BadRequest) as exc_info:
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
("foo\u00A0".encode(), b"bar"),
],
1,
False,
)
)
assert exc_info.value.status_code == 400
assert (
str(exc_info.value)
== "Header names may only contain US-ASCII characters."
)
def test_request_url_encoding(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
with pytest.raises(BadRequest) as exc_info:
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location\xA0"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
assert exc_info.value.status_code == 400
assert str(exc_info.value) == "URL may only contain US-ASCII characters."

View File

@@ -36,7 +36,6 @@ def test_app_loop_running(app: Sanic):
assert response.text == "pass"
@pytest.mark.asyncio
def test_create_asyncio_server(app: Sanic):
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(return_asyncio_server=True)
@@ -45,7 +44,6 @@ def test_create_asyncio_server(app: Sanic):
assert srv.is_serving() is True
@pytest.mark.asyncio
def test_asyncio_server_no_start_serving(app: Sanic):
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(
@@ -57,7 +55,6 @@ def test_asyncio_server_no_start_serving(app: Sanic):
assert srv.is_serving() is False
@pytest.mark.asyncio
def test_asyncio_server_start_serving(app: Sanic):
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(
@@ -75,7 +72,6 @@ def test_asyncio_server_start_serving(app: Sanic):
# Looks like we can't easily test `serve_forever()`
@pytest.mark.asyncio
def test_create_server_main(app: Sanic, caplog):
app.listener("main_process_start")(lambda *_: ...)
loop = asyncio.get_event_loop()
@@ -90,7 +86,6 @@ def test_create_server_main(app: Sanic, caplog):
) in caplog.record_tuples
@pytest.mark.asyncio
def test_create_server_no_startup(app: Sanic):
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(
@@ -106,7 +101,6 @@ def test_create_server_no_startup(app: Sanic):
loop.run_until_complete(srv.start_serving())
@pytest.mark.asyncio
def test_create_server_main_convenience(app: Sanic, caplog):
app.main_process_start(lambda *_: ...)
loop = asyncio.get_event_loop()
@@ -132,6 +126,7 @@ def test_app_loop_not_running(app: Sanic):
def test_app_run_raise_type_error(app: Sanic):
with pytest.raises(TypeError) as excinfo:
app.run(loop="loop")
@@ -144,6 +139,7 @@ def test_app_run_raise_type_error(app: Sanic):
def test_app_route_raise_value_error(app: Sanic):
with pytest.raises(ValueError) as excinfo:
@app.route("/test")
@@ -225,6 +221,7 @@ def test_app_websocket_parameters(websocket_protocol_mock, app: Sanic):
def test_handle_request_with_nested_exception(app: Sanic, monkeypatch):
err_msg = "Mock Exception"
def mock_error_handler_response(*args, **kwargs):
@@ -244,6 +241,7 @@ def test_handle_request_with_nested_exception(app: Sanic, monkeypatch):
def test_handle_request_with_nested_exception_debug(app: Sanic, monkeypatch):
err_msg = "Mock Exception"
def mock_error_handler_response(*args, **kwargs):
@@ -448,7 +446,7 @@ def test_custom_context():
@pytest.mark.parametrize("use", (False, True))
def test_uvloop_config(app: Sanic, monkeypatch, use):
@app.get("/test", name="test")
@app.get("/test")
def handler(request):
return text("ok")
@@ -472,7 +470,6 @@ def test_uvloop_config(app: Sanic, monkeypatch, use):
try_use_uvloop.assert_not_called()
@pytest.mark.asyncio
def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
apps = (Sanic("default-uvloop"), Sanic("no-uvloop"), Sanic("yes-uvloop"))
@@ -509,7 +506,6 @@ def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
assert counter[(logging.WARNING, message)] == modified
@pytest.mark.asyncio
def test_multiple_uvloop_configs_display_warning(caplog):
Sanic._uvloop_setting = None # Reset the setting (changed in prev tests)
@@ -571,6 +567,21 @@ def test_cannot_run_single_process_and_workers_or_auto_reload(
app.run(single_process=True, **extra)
def test_cannot_run_single_process_and_legacy(app: Sanic):
message = "Cannot run single process and legacy mode"
with pytest.raises(RuntimeError, match=message):
app.run(single_process=True, legacy=True)
def test_cannot_run_without_sys_signals_with_workers(app: Sanic):
message = (
"Cannot run Sanic.serve with register_sys_signals=False. "
"Use either Sanic.serve_single or Sanic.serve_legacy."
)
with pytest.raises(RuntimeError, match=message):
app.run(register_sys_signals=False, single_process=False, legacy=False)
def test_default_configure_logging():
with patch("sanic.app.logging") as mock:
Sanic("Test")

View File

@@ -2,17 +2,13 @@ import asyncio
import logging
from collections import deque, namedtuple
from unittest.mock import call
import pytest
import uvicorn
from httpx import Headers
from pytest import MonkeyPatch
from sanic import Sanic
from sanic.application.state import Mode
from sanic.asgi import ASGIApp, Lifespan, MockTransport
from sanic.asgi import ASGIApp, MockTransport
from sanic.exceptions import BadRequest, Forbidden, ServiceUnavailable
from sanic.request import Request
from sanic.response import json, text
@@ -120,6 +116,10 @@ def test_listeners_triggered(caplog):
stop_message,
) not in caplog.record_tuples
all_tasks = asyncio.all_tasks(asyncio.get_event_loop())
for task in all_tasks:
task.cancel()
assert before_server_start
assert after_server_start
assert before_server_stop
@@ -218,6 +218,10 @@ def test_listeners_triggered_async(app, caplog):
stop_message,
) not in caplog.record_tuples
all_tasks = asyncio.all_tasks(asyncio.get_event_loop())
for task in all_tasks:
task.cancel()
assert before_server_start
assert after_server_start
assert before_server_stop
@@ -268,6 +272,10 @@ def test_non_default_uvloop_config_raises_warning(app):
with pytest.warns(UserWarning) as records:
server.run()
all_tasks = asyncio.all_tasks(asyncio.get_event_loop())
for task in all_tasks:
task.cancel()
msg = ""
for record in records:
_msg = str(record.message)
@@ -334,7 +342,7 @@ async def test_websocket_send(send, receive, message_stack):
@pytest.mark.asyncio
async def test_websocket_text_receive(send, receive, message_stack):
async def test_websocket_receive(send, receive, message_stack):
msg = {"text": "hello", "type": "websocket.receive"}
message_stack.append(msg)
@@ -344,17 +352,6 @@ async def test_websocket_text_receive(send, receive, message_stack):
assert text == msg["text"]
@pytest.mark.asyncio
async def test_websocket_bytes_receive(send, receive, message_stack):
msg = {"bytes": b"hello", "type": "websocket.receive"}
message_stack.append(msg)
ws = WebSocketConnection(send, receive)
data = await ws.receive()
assert data == msg["bytes"]
@pytest.mark.asyncio
async def test_websocket_accept_with_no_subprotocols(
send, receive, message_stack
@@ -575,28 +572,15 @@ async def test_error_on_lifespan_exception_start(app, caplog):
async def before_server_start(_):
1 / 0
recv = AsyncMock(
side_effect=[
{"type": "lifespan.startup"},
{"type": "lifespan.shutdown"},
]
)
recv = AsyncMock(return_value={"type": "lifespan.startup"})
send = AsyncMock()
app.asgi = True
lifespan = Lifespan(app, {"type": "lifespan"}, recv, send)
with caplog.at_level(logging.ERROR):
await lifespan()
await ASGIApp.create(app, {"type": "lifespan"}, recv, send)
send.assert_has_calls(
[
call(
{
"type": "lifespan.startup.failed",
"message": "division by zero",
}
)
]
send.assert_awaited_once_with(
{"type": "lifespan.startup.failed", "message": "division by zero"}
)
@@ -606,63 +590,13 @@ async def test_error_on_lifespan_exception_stop(app: Sanic):
async def before_server_stop(_):
1 / 0
recv = AsyncMock(
side_effect=[
{"type": "lifespan.startup"},
{"type": "lifespan.shutdown"},
]
)
recv = AsyncMock(return_value={"type": "lifespan.shutdown"})
send = AsyncMock()
app.asgi = True
await app._startup()
lifespan = Lifespan(app, {"type": "lifespan"}, recv, send)
await lifespan()
await ASGIApp.create(app, {"type": "lifespan"}, recv, send)
send.assert_has_calls(
[
call(
{
"type": "lifespan.shutdown.failed",
"message": "division by zero",
}
)
]
send.assert_awaited_once_with(
{"type": "lifespan.shutdown.failed", "message": "division by zero"}
)
@pytest.mark.asyncio
async def test_asgi_headers_decoding(app: Sanic, monkeypatch: MonkeyPatch):
@app.get("/")
def handler(request: Request):
return text("")
headers_init = Headers.__init__
def mocked_headers_init(self, *args, **kwargs):
if "encoding" in kwargs:
kwargs.pop("encoding")
headers_init(self, encoding="utf-8", *args, **kwargs)
monkeypatch.setattr(Headers, "__init__", mocked_headers_init)
message = "Header names can only contain US-ASCII characters"
with pytest.raises(BadRequest, match=message):
_, response = await app.asgi_client.get("/", headers={"😂": "😅"})
_, response = await app.asgi_client.get("/", headers={"Test-Header": "😅"})
assert response.status_code == 200
@pytest.mark.asyncio
async def test_asgi_url_decoding(app):
@app.get("/dir/<name>", unquote=True)
def _request(request: Request, name):
return text(name)
# 2F should not become a path separator (unquoted later)
_, response = await app.asgi_client.get("/dir/some%2Fpath")
assert response.text == "some/path"
_, response = await app.asgi_client.get("/dir/some%F0%9F%98%80path")
assert response.text == "some😀path"

View File

@@ -66,11 +66,3 @@ def test_bp_copy(app: Sanic):
_, response = app.test_client.get("/version6/page")
assert "Hello world!" in response.text
route_names = [route.name for route in app.router.routes]
assert "test_bp_copy.test_bp1.handle_request" in route_names
assert "test_bp_copy.test_bp2.handle_request" in route_names
assert "test_bp_copy.test_bp3.handle_request" in route_names
assert "test_bp_copy.test_bp4.handle_request" in route_names
assert "test_bp_copy.test_bp5.handle_request" in route_names
assert "test_bp_copy.test_bp6.handle_request" in route_names

View File

@@ -303,10 +303,6 @@ def test_bp_with_host_list(app: Sanic):
assert response.text == "Hello subdomain!"
route_names = [r.name for r in app.router.routes]
assert "test_bp_with_host_list.test_bp_host.handler1" in route_names
assert "test_bp_with_host_list.test_bp_host.handler2" in route_names
def test_several_bp_with_host_list(app: Sanic):
bp = Blueprint(

View File

@@ -43,10 +43,8 @@ def read_app_info(lines: List[str]):
"appname,extra",
(
("fake.server.app", None),
("fake.server", None),
("fake.server:create_app", "--factory"),
("fake.server.create_app()", None),
("fake.server.create_app", None),
),
)
def test_server_run(
@@ -62,17 +60,14 @@ def test_server_run(
assert "Goin' Fast @ http://127.0.0.1:8000" in lines
@pytest.mark.parametrize(
"command",
(
["fake.server.create_app_with_args", "--factory"],
["fake.server.create_app_with_args"],
),
)
def test_server_run_factory_with_args(caplog, command):
def test_server_run_factory_with_args(caplog):
command = [
"fake.server.create_app_with_args",
"--factory",
]
lines = capture(command, caplog)
assert "target=fake.server.create_app_with_args" in lines
assert "module=fake.server.create_app_with_args" in lines
def test_server_run_factory_with_args_arbitrary(caplog):
@@ -86,6 +81,25 @@ def test_server_run_factory_with_args_arbitrary(caplog):
assert "foo=bar" in lines
def test_error_with_function_as_instance_without_factory_arg(caplog):
command = ["fake.server.create_app"]
lines = capture(command, caplog)
assert (
"Failed to run app: Module is not a Sanic app, it is a function\n "
"If this callable returns a Sanic instance try: \n"
"sanic fake.server.create_app --factory"
) in lines
def test_error_with_path_as_instance_without_simple_arg(caplog):
command = ["./fake/"]
lines = capture(command, caplog)
assert (
"Failed to run app: App not found.\n Please use --simple if you "
"are passing a directory to sanic.\n eg. sanic ./fake/ --simple"
) in lines
@pytest.mark.parametrize(
"cmd",
(

View File

@@ -1,16 +1,11 @@
from datetime import datetime, timedelta
from http.cookies import SimpleCookie
from unittest.mock import Mock
import pytest
from sanic import Request, Sanic
from sanic.compat import Header
from sanic.cookies import Cookie, CookieJar
from sanic.cookies.request import CookieRequestParameters
from sanic.exceptions import ServerError
from sanic import Sanic
from sanic.cookies import Cookie
from sanic.response import text
from sanic.response.convenience import json
# ------------------------------------------------------------ #
@@ -116,23 +111,21 @@ def test_cookie_options(app):
def test_cookie_deletion(app):
cookie_jar = None
@app.route("/")
def handler(request):
nonlocal cookie_jar
response = text("OK")
del response.cookies["one"]
response.cookies["two"] = "testing"
del response.cookies["two"]
cookie_jar = response.cookies
del response.cookies["i_want_to_die"]
response.cookies["i_never_existed"] = "testing"
del response.cookies["i_never_existed"]
return response
_, response = app.test_client.get("/")
request, response = app.test_client.get("/")
response_cookies = SimpleCookie()
response_cookies.load(response.headers.get("Set-Cookie", {}))
assert cookie_jar.get_cookie("one").max_age == 0
assert cookie_jar.get_cookie("two").max_age == 0
assert len(response.cookies) == 0
assert int(response_cookies["i_want_to_die"]["max-age"]) == 0
with pytest.raises(KeyError):
response.cookies["i_never_existed"]
def test_cookie_reserved_cookie():
@@ -155,6 +148,7 @@ def test_cookie_set_unknown_property():
def test_cookie_set_same_key(app):
cookies = {"test": "wait"}
@app.get("/")
@@ -259,262 +253,3 @@ def test_cookie_expires_illegal_instance_type(expires):
with pytest.raises(expected_exception=TypeError) as e:
c["expires"] = expires
assert e.message == "Cookie 'expires' property must be a datetime"
@pytest.mark.parametrize("value", ("foo=one; foo=two", "foo=one;foo=two"))
def test_request_with_duplicate_cookie_key(value):
headers = Header({"Cookie": value})
request = Request(b"/", headers, "1.1", "GET", Mock(), Mock())
assert request.cookies["foo"] == "one"
assert request.cookies.get("foo") == "one"
assert request.cookies.getlist("foo") == ["one", "two"]
assert request.cookies.get("bar") is None
def test_cookie_jar_cookies():
headers = Header()
jar = CookieJar(headers)
jar.add_cookie("foo", "one")
jar.add_cookie("foo", "two", domain="example.com")
assert len(jar.cookies) == 2
assert len(headers) == 2
def test_cookie_jar_has_cookie():
headers = Header()
jar = CookieJar(headers)
jar.add_cookie("foo", "one")
jar.add_cookie("foo", "two", domain="example.com")
assert jar.has_cookie("foo")
assert jar.has_cookie("foo", domain="example.com")
assert not jar.has_cookie("foo", path="/unknown")
assert not jar.has_cookie("bar")
def test_cookie_jar_get_cookie():
headers = Header()
jar = CookieJar(headers)
cookie1 = jar.add_cookie("foo", "one")
cookie2 = jar.add_cookie("foo", "two", domain="example.com")
assert jar.get_cookie("foo") is cookie1
assert jar.get_cookie("foo", domain="example.com") is cookie2
assert jar.get_cookie("foo", path="/unknown") is None
assert jar.get_cookie("bar") is None
def test_cookie_jar_add_cookie_encode():
headers = Header()
jar = CookieJar(headers)
jar.add_cookie("foo", "one")
jar.add_cookie(
"foo",
"two",
domain="example.com",
path="/something",
secure=True,
max_age=999,
httponly=True,
samesite="strict",
)
jar.add_cookie("foo", "three", secure_prefix=True)
jar.add_cookie("foo", "four", host_prefix=True)
jar.add_cookie("foo", "five", host_prefix=True, partitioned=True)
encoded = [cookie.encode("ascii") for cookie in jar.cookies]
assert encoded == [
b"foo=one; Path=/; SameSite=Lax; Secure",
b"foo=two; Path=/something; Domain=example.com; Max-Age=999; SameSite=Strict; Secure; HttpOnly", # noqa
b"__Secure-foo=three; Path=/; SameSite=Lax; Secure",
b"__Host-foo=four; Path=/; SameSite=Lax; Secure",
b"__Host-foo=five; Path=/; SameSite=Lax; Secure; Partitioned",
]
def test_cookie_jar_old_school_cookie_encode():
headers = Header()
jar = CookieJar(headers)
jar["foo"] = "one"
jar["bar"] = "two"
jar["bar"]["domain"] = "example.com"
jar["bar"]["path"] = "/something"
jar["bar"]["secure"] = True
jar["bar"]["max-age"] = 999
jar["bar"]["httponly"] = True
jar["bar"]["samesite"] = "strict"
encoded = [cookie.encode("ascii") for cookie in jar.cookies]
assert encoded == [
b"foo=one; Path=/",
b"bar=two; Path=/something; Domain=example.com; Max-Age=999; SameSite=Strict; Secure; HttpOnly", # noqa
]
def test_cookie_jar_delete_cookie_encode():
headers = Header()
jar = CookieJar(headers)
jar.delete_cookie("foo")
jar.delete_cookie("foo", domain="example.com")
encoded = [cookie.encode("ascii") for cookie in jar.cookies]
assert encoded == [
b'foo=""; Path=/; Max-Age=0; Secure',
b'foo=""; Path=/; Domain=example.com; Max-Age=0; Secure',
]
def test_cookie_jar_old_school_delete_encode():
headers = Header()
jar = CookieJar(headers)
del jar["foo"]
encoded = [cookie.encode("ascii") for cookie in jar.cookies]
assert encoded == [
b'foo=""; Path=/; Max-Age=0; Secure',
]
def test_bad_cookie_prarms():
headers = Header()
jar = CookieJar(headers)
with pytest.raises(
ServerError,
match=(
"Both host_prefix and secure_prefix were requested. "
"A cookie should have only one prefix."
),
):
jar.add_cookie("foo", "bar", host_prefix=True, secure_prefix=True)
with pytest.raises(
ServerError,
match="Cannot set host_prefix on a cookie without secure=True",
):
jar.add_cookie("foo", "bar", host_prefix=True, secure=False)
with pytest.raises(
ServerError,
match="Cannot set host_prefix on a cookie unless path='/'",
):
jar.add_cookie(
"foo", "bar", host_prefix=True, secure=True, path="/foo"
)
with pytest.raises(
ServerError,
match="Cannot set host_prefix on a cookie with a defined domain",
):
jar.add_cookie(
"foo", "bar", host_prefix=True, secure=True, domain="foo.bar"
)
with pytest.raises(
ServerError,
match="Cannot set secure_prefix on a cookie without secure=True",
):
jar.add_cookie("foo", "bar", secure_prefix=True, secure=False)
with pytest.raises(
ServerError,
match=(
"Cannot create a partitioned cookie without "
"also setting host_prefix=True"
),
):
jar.add_cookie("foo", "bar", partitioned=True)
def test_cookie_accessors(app: Sanic):
@app.get("/")
async def handler(request: Request):
return json(
{
"getitem": {
"one": request.cookies["one"],
"two": request.cookies["two"],
"three": request.cookies["three"],
},
"get": {
"one": request.cookies.get("one", "fallback"),
"two": request.cookies.get("two", "fallback"),
"three": request.cookies.get("three", "fallback"),
"four": request.cookies.get("four", "fallback"),
},
"getlist": {
"one": request.cookies.getlist("one", ["fallback"]),
"two": request.cookies.getlist("two", ["fallback"]),
"three": request.cookies.getlist("three", ["fallback"]),
"four": request.cookies.getlist("four", ["fallback"]),
},
"getattr": {
"one": request.cookies.one,
"two": request.cookies.two,
"three": request.cookies.three,
"four": request.cookies.four,
},
}
)
_, response = app.test_client.get(
"/",
cookies={
"__Host-one": "1",
"__Secure-two": "2",
"three": "3",
},
)
assert response.json == {
"getitem": {
"one": "1",
"two": "2",
"three": "3",
},
"get": {
"one": "1",
"two": "2",
"three": "3",
"four": "fallback",
},
"getlist": {
"one": ["1"],
"two": ["2"],
"three": ["3"],
"four": ["fallback"],
},
"getattr": {
"one": "1",
"two": "2",
"three": "3",
"four": "",
},
}
def test_cookie_accessor_hyphens():
cookies = CookieRequestParameters({"session-token": ["abc123"]})
assert cookies.get("session-token") == cookies.session_token
def test_cookie_passthru(app):
cookie_jar = None
@app.route("/")
def handler(request):
nonlocal cookie_jar
response = text("OK")
response.add_cookie("one", "1", host_prefix=True)
response.delete_cookie("two", secure_prefix=True)
cookie_jar = response.cookies
return response
_, response = app.test_client.get("/")
assert cookie_jar.get_cookie("two", secure_prefix=True).max_age == 0
assert len(response.cookies) == 1
assert response.cookies["__Host-one"] == "1"

View File

@@ -1,14 +1,12 @@
import logging
import pytest
from sanic import Sanic
from sanic.config import Config
from sanic.errorpages import TextRenderer, exception_response, guess_mime
from sanic.errorpages import HTMLRenderer, exception_response
from sanic.exceptions import NotFound, SanicException
from sanic.handlers import ErrorHandler
from sanic.request import Request
from sanic.response import HTTPResponse, empty, html, json, text
from sanic.response import HTTPResponse, html, json, text
@pytest.fixture
@@ -19,44 +17,6 @@ def app():
def err(request):
raise Exception("something went wrong")
@app.get("/forced_json/<fail>", error_format="json")
def manual_fail(request, fail):
if fail == "fail":
raise Exception
return html("") # Should be ignored
@app.get("/empty/<fail>")
def empty_fail(request, fail):
if fail == "fail":
raise Exception
return empty()
@app.get("/json/<fail>")
def json_fail(request, fail):
if fail == "fail":
raise Exception
# After 23.3 route format should become json, older versions think it
# is mixed due to empty mapping to html, and don't find any format.
return json({"foo": "bar"}) if fail == "json" else empty()
@app.get("/html/<fail>")
def html_fail(request, fail):
if fail == "fail":
raise Exception
return html("<h1>foo</h1>")
@app.get("/text/<fail>")
def text_fail(request, fail):
if fail == "fail":
raise Exception
return text("foo")
@app.get("/mixed/<param>")
def mixed_fail(request, param):
if param not in ("json", "html"):
raise Exception
return json({}) if param == "json" else html("")
return app
@@ -68,14 +28,14 @@ def fake_request(app):
@pytest.mark.parametrize(
"fallback,content_type, exception, status",
(
(None, "text/plain; charset=utf-8", Exception, 500),
(None, "text/html; charset=utf-8", Exception, 500),
("html", "text/html; charset=utf-8", Exception, 500),
("auto", "text/plain; charset=utf-8", Exception, 500),
("auto", "text/html; charset=utf-8", Exception, 500),
("text", "text/plain; charset=utf-8", Exception, 500),
("json", "application/json", Exception, 500),
(None, "text/plain; charset=utf-8", NotFound, 404),
(None, "text/html; charset=utf-8", NotFound, 404),
("html", "text/html; charset=utf-8", NotFound, 404),
("auto", "text/plain; charset=utf-8", NotFound, 404),
("auto", "text/html; charset=utf-8", NotFound, 404),
("text", "text/plain; charset=utf-8", NotFound, 404),
("json", "application/json", NotFound, 404),
),
@@ -83,10 +43,6 @@ def fake_request(app):
def test_should_return_html_valid_setting(
fake_request, fallback, content_type, exception, status
):
# Note: if fallback is None or "auto", prior to PR #2668 base was returned
# and after that a text response is given because it matches */*. Changed
# base to TextRenderer in this test, like it is in Sanic itself, so the
# test passes with either version but still covers everything that it did.
if fallback:
fake_request.app.config.FALLBACK_ERROR_FORMAT = fallback
@@ -97,7 +53,7 @@ def test_should_return_html_valid_setting(
fake_request,
e,
True,
base=TextRenderer,
base=HTMLRenderer,
fallback=fake_request.app.config.FALLBACK_ERROR_FORMAT,
)
@@ -248,9 +204,9 @@ def test_fallback_with_content_type_mismatch_accept(app):
app.router.reset()
@app.route("/alt1", name="alt1")
@app.route("/alt2", error_format="text", name="alt2")
@app.route("/alt3", error_format="html", name="alt3")
@app.route("/alt1")
@app.route("/alt2", error_format="text")
@app.route("/alt3", error_format="html")
def handler(_):
raise Exception("problem here")
# Yes, we know this return value is unreachable. This is on purpose.
@@ -303,16 +259,15 @@ def test_fallback_with_content_type_mismatch_accept(app):
"accept,content_type,expected",
(
(None, None, "text/plain; charset=utf-8"),
("foo/bar", None, "text/plain; charset=utf-8"),
("foo/bar", None, "text/html; charset=utf-8"),
("application/json", None, "application/json"),
("application/json,text/plain", None, "application/json"),
("text/plain,application/json", None, "application/json"),
("text/plain,foo/bar", None, "text/plain; charset=utf-8"),
("text/plain,text/html", None, "text/plain; charset=utf-8"),
("*/*", "foo/bar", "text/plain; charset=utf-8"),
# Following test is valid after v22.3
# ("text/plain,text/html", None, "text/plain; charset=utf-8"),
("*/*", "foo/bar", "text/html; charset=utf-8"),
("*/*", "application/json", "application/json"),
# App wants text/plain but accept has equal entries for it
("text/*,*/plain", None, "text/plain; charset=utf-8"),
),
)
def test_combinations_for_auto(fake_request, accept, content_type, expected):
@@ -331,7 +286,7 @@ def test_combinations_for_auto(fake_request, accept, content_type, expected):
fake_request,
e,
True,
base=TextRenderer,
base=HTMLRenderer,
fallback="auto",
)
@@ -421,109 +376,3 @@ def test_config_fallback_bad_value(app):
message = "Unknown format: fake"
with pytest.raises(SanicException, match=message):
app.config.FALLBACK_ERROR_FORMAT = "fake"
@pytest.mark.parametrize(
"route_format,fallback,accept,expected",
(
(
"json",
"html",
"*/*",
"The client accepts */*, using 'json' from fakeroute",
),
(
"json",
"auto",
"text/html,*/*;q=0.8",
"The client accepts text/html, using 'html' from any",
),
(
"json",
"json",
"text/html,*/*;q=0.8",
"The client accepts */*;q=0.8, using 'json' from fakeroute",
),
(
"",
"html",
"text/*,*/plain",
"The client accepts text/*, using 'html' from FALLBACK_ERROR_FORMAT",
),
(
"",
"json",
"text/*,*/*",
"The client accepts */*, using 'json' from FALLBACK_ERROR_FORMAT",
),
(
"",
"auto",
"*/*,application/json;q=0.5",
"The client accepts */*, using 'json' from request.accept",
),
(
"",
"auto",
"*/*",
"The client accepts */*, using 'json' from content-type",
),
(
"",
"auto",
"text/html,text/plain",
"The client accepts text/plain, using 'text' from any",
),
(
"",
"auto",
"text/html,text/plain;q=0.9",
"The client accepts text/html, using 'html' from any",
),
(
"html",
"json",
"application/xml",
"No format found, the client accepts [application/xml]",
),
("", "auto", "*/*", "The client accepts */*, using 'text' from any"),
("", "", "*/*", "No format found, the client accepts [*/*]"),
# DEPRECATED: remove in 24.3
(
"",
"auto",
"*/*",
"The client accepts */*, using 'json' from request.json",
),
),
)
def test_guess_mime_logging(
caplog, fake_request, route_format, fallback, accept, expected
):
class FakeObject:
pass
fake_request.route = FakeObject()
fake_request.route.name = "fakeroute"
fake_request.route.extra = FakeObject()
fake_request.route.extra.error_format = route_format
if accept is None:
del fake_request.headers["accept"]
else:
fake_request.headers["accept"] = accept
if "content-type" in expected:
fake_request.headers["content-type"] = "application/json"
# Fake JSON content (DEPRECATED: remove in 24.3)
if "request.json" in expected:
fake_request.parsed_json = {"foo": "bar"}
with caplog.at_level(logging.DEBUG, logger="sanic.root"):
guess_mime(fake_request, fallback)
(logmsg,) = [
r.message for r in caplog.records if r.funcName == "guess_mime"
]
assert logmsg == expected

View File

@@ -23,11 +23,11 @@ from sanic.exceptions import (
from sanic.response import text
def dl_to_dict(soup, dl_id):
def dl_to_dict(soup, css_class):
keys, values = [], []
for dl in soup.find_all("dl", {"id": dl_id}):
for dl in soup.find_all("dl", {"class": css_class}):
for dt in dl.find_all("dt"):
keys.append(dt.text.split(":", 1)[0])
keys.append(dt.text.strip())
for dd in dl.find_all("dd"):
values.append(dd.text.strip())
return dict(zip(keys, values))
@@ -194,7 +194,10 @@ def test_handled_unhandled_exception(exception_app):
assert "Internal Server Error" in soup.h1.text
message = " ".join(soup.p.text.split())
assert "The application encountered an unexpected error" in message
assert message == (
"The server encountered an internal error and "
"cannot complete your request."
)
def test_exception_in_exception_handler(exception_app):
@@ -285,15 +288,9 @@ def test_contextual_exception_context(debug):
def fail():
raise TeapotError(context={"foo": "bar"})
app.post("/coffee/json", error_format="json", name="json")(
lambda _: fail()
)
app.post("/coffee/html", error_format="html", name="html")(
lambda _: fail()
)
app.post("/coffee/text", error_format="text", name="text")(
lambda _: fail()
)
app.post("/coffee/json", error_format="json")(lambda _: fail())
app.post("/coffee/html", error_format="html")(lambda _: fail())
app.post("/coffee/text", error_format="text")(lambda _: fail())
_, response = app.test_client.post("/coffee/json", debug=debug)
assert response.status == 418
@@ -302,7 +299,7 @@ def test_contextual_exception_context(debug):
_, response = app.test_client.post("/coffee/html", debug=debug)
soup = BeautifulSoup(response.body, "html.parser")
dl = dl_to_dict(soup, "exception-context")
dl = dl_to_dict(soup, "context")
assert response.status == 418
assert "Sorry, I cannot brew coffee" in soup.find("p").text
assert dl == {"foo": "bar"}
@@ -329,15 +326,9 @@ def test_contextual_exception_extra(debug):
def fail():
raise TeapotError(extra={"foo": "bar"})
app.post("/coffee/json", error_format="json", name="json")(
lambda _: fail()
)
app.post("/coffee/html", error_format="html", name="html")(
lambda _: fail()
)
app.post("/coffee/text", error_format="text", name="text")(
lambda _: fail()
)
app.post("/coffee/json", error_format="json")(lambda _: fail())
app.post("/coffee/html", error_format="html")(lambda _: fail())
app.post("/coffee/text", error_format="text")(lambda _: fail())
_, response = app.test_client.post("/coffee/json", debug=debug)
assert response.status == 418
@@ -349,7 +340,7 @@ def test_contextual_exception_extra(debug):
_, response = app.test_client.post("/coffee/html", debug=debug)
soup = BeautifulSoup(response.body, "html.parser")
dl = dl_to_dict(soup, "exception-extra")
dl = dl_to_dict(soup, "extra")
assert response.status == 418
assert "Found bar" in soup.find("p").text
if debug:

View File

@@ -62,6 +62,7 @@ def exception_handler_app():
@exception_handler_app.route("/8", error_format="html")
def handler_8(request):
raise ErrorWithRequestCtx("OK")
@exception_handler_app.exception(ErrorWithRequestCtx, NotFound)
@@ -123,10 +124,10 @@ def test_html_traceback_output_in_debug_mode(exception_handler_app: Sanic):
assert "handler_4" in html
assert "foo = bar" in html
summary_text = soup.select("h3")[0].text
assert "NameError: name 'bar' is not defined" == summary_text
request_text = soup.select("h2")[-1].text
assert "GET /4" == request_text
summary_text = " ".join(soup.select(".summary")[0].text.split())
assert (
"NameError: name 'bar' is not defined while handling path /4"
) == summary_text
def test_inherited_exception_handler(exception_handler_app: Sanic):
@@ -146,10 +147,11 @@ def test_chained_exception_handler(exception_handler_app: Sanic):
assert "handler_6" in html
assert "foo = 1 / arg" in html
assert "ValueError" in html
assert "GET /6" in html
summary_text = soup.select("h3")[0].text
assert "ZeroDivisionError: division by zero" == summary_text
summary_text = " ".join(soup.select(".summary")[0].text.split())
assert (
"ZeroDivisionError: division by zero while handling path /6/0"
) == summary_text
def test_exception_handler_lookup(exception_handler_app: Sanic):
@@ -212,7 +214,7 @@ def test_error_handler_noisy_log(
exception_handler_app: Sanic, monkeypatch: MonkeyPatch
):
err_logger = Mock()
monkeypatch.setattr(handlers.error, "error_logger", err_logger)
monkeypatch.setattr(handlers, "error_logger", err_logger)
exception_handler_app.config["NOISY_EXCEPTIONS"] = False
exception_handler_app.test_client.get("/1")
@@ -266,17 +268,20 @@ def test_exception_handler_response_was_sent(
assert "Error" in response.text
def test_errir_on_duplicate(app: Sanic):
def test_warn_on_duplicate(
app: Sanic, caplog: LogCaptureFixture, recwarn: WarningsRecorder
):
@app.exception(ServerError)
async def exception_handler_1(request, exception):
...
message = (
@app.exception(ServerError)
async def exception_handler_2(request, exception):
...
assert len(caplog.records) == 1
assert len(recwarn) == 1
assert caplog.records[0].message == (
"Duplicate exception handler definition on: route=__ALL_ROUTES__ and "
"exception=<class 'sanic.exceptions.ServerError'>"
)
with pytest.raises(ServerError, match=message):
@app.exception(ServerError)
async def exception_handler_2(request, exception):
...

View File

@@ -2,16 +2,12 @@ from unittest.mock import Mock
import pytest
from sanic import Sanic, headers, json, text
from sanic import headers, text
from sanic.exceptions import InvalidHeader, PayloadTooLarge
from sanic.http import Http
from sanic.request import Request
def make_request(headers) -> Request:
return Request(b"/", headers, "1.1", "GET", None, None)
@pytest.fixture
def raised_ceiling():
Http.HEADER_CEILING = 32_768
@@ -49,17 +45,29 @@ def raised_ceiling():
("attachment", {"filename": "strange;name", "size": "123"}),
),
(
'form-data; name="foo"; value="%22\\%0D%0A"',
("form-data", {"name": "foo", "value": '"\\\n'}),
'form-data; name="files"; filename="fo\\"o;bar\\"',
("form-data", {"name": "files", "filename": 'fo"o;bar\\'})
# cgi.parse_header:
# ('form-data', {'name': 'files', 'filename': 'fo"o;bar\\'})
# werkzeug.parse_options_header:
# ('form-data', {'name': 'files', 'filename': '"fo\\"o', 'bar\\"': None})
),
# <input type=file name="foo&quot;;bar\"> with Unicode filename!
(
# Chrome, Firefox:
# Chrome:
# Content-Disposition: form-data; name="foo%22;bar\"; filename="😀"
'form-data; name="foo%22;bar\\"; filename="😀"',
("form-data", {"name": 'foo";bar\\', "filename": "😀"})
# cgi: ('form-data', {'name': 'foo%22;bar"; filename="😀'})
# werkzeug (pre 2.3.0): ('form-data', {'name': 'foo%22;bar"; filename='})
# werkzeug: ('form-data', {'name': 'foo%22;bar"; filename='})
),
(
# Firefox:
# Content-Disposition: form-data; name="foo\";bar\"; filename="😀"
'form-data; name="foo\\";bar\\"; filename="😀"',
("form-data", {"name": 'foo";bar\\', "filename": "😀"})
# cgi: ('form-data', {'name': 'foo";bar"; filename="😀'})
# werkzeug: ('form-data', {'name': 'foo";bar"; filename='})
),
],
)
@@ -179,24 +187,27 @@ def test_request_line(app):
@pytest.mark.parametrize(
"raw,expected_subtype",
"raw",
(
("show/first, show/second", "first"),
("show/*, show/first", "first"),
("*/*, show/first", "first"),
("*/*, show/*", "*"),
("other/*; q=0.1, show/*; q=0.2", "*"),
("show/first; q=0.5, show/second; q=0.5", "first"),
("show/first; foo=bar, show/second; foo=bar", "first"),
("show/second, show/first; foo=bar", "first"),
("show/second; q=0.5, show/first; foo=bar; q=0.5", "first"),
("show/second; q=0.5, show/first; q=1.0", "first"),
("show/first, show/second; q=1.0", "second"),
"show/first, show/second",
"show/*, show/first",
"*/*, show/first",
"*/*, show/*",
"other/*; q=0.1, show/*; q=0.2",
"show/first; q=0.5, show/second; q=0.5",
"show/first; foo=bar, show/second; foo=bar",
"show/second, show/first; foo=bar",
"show/second; q=0.5, show/first; foo=bar; q=0.5",
"show/second; q=0.5, show/first; q=1.0",
"show/first, show/second; q=1.0",
),
)
def test_parse_accept_ordered_okay(raw, expected_subtype):
def test_parse_accept_ordered_okay(raw):
ordered = headers.parse_accept(raw)
assert ordered[0].type == "show"
expected_subtype = (
"*" if all(q.subtype.is_wildcard for q in ordered) else "first"
)
assert ordered[0].type_ == "show"
assert ordered[0].subtype == expected_subtype
@@ -206,7 +217,6 @@ def test_parse_accept_ordered_okay(raw, expected_subtype):
"missing",
"missing/",
"/missing",
"/",
),
)
def test_bad_accept(raw):
@@ -215,83 +225,128 @@ def test_bad_accept(raw):
def test_empty_accept():
a = headers.parse_accept("")
assert a == []
assert not a.match("*/*")
assert headers.parse_accept("") == []
def test_wildcard_accept_set_ok():
accept = headers.parse_accept("*/*")[0]
assert accept.type == "*"
assert accept.subtype == "*"
assert accept.has_wildcard
accept = headers.parse_accept("foo/*")[0]
assert accept.type == "foo"
assert accept.subtype == "*"
assert accept.has_wildcard
assert accept.type_.is_wildcard
assert accept.subtype.is_wildcard
accept = headers.parse_accept("foo/bar")[0]
assert accept.type == "foo"
assert accept.subtype == "bar"
assert not accept.has_wildcard
assert not accept.type_.is_wildcard
assert not accept.subtype.is_wildcard
def test_accept_parsed_against_str():
accept = headers.Matched.parse("foo/bar")
assert accept == "foo/bar; q=0.1"
accept = headers.Accept.parse("foo/bar")
assert accept > "foo/bar; q=0.1"
def test_media_type_equality():
assert headers.MediaType("foo") == headers.MediaType("foo") == "foo"
assert headers.MediaType("foo") == headers.MediaType("*") == "*"
assert headers.MediaType("foo") != headers.MediaType("bar")
assert headers.MediaType("foo") != "bar"
def test_media_type_matching():
assert headers.MediaType("foo", "bar").match(
headers.MediaType("foo", "bar")
)
assert headers.MediaType("foo", "bar").match("foo/bar")
assert headers.MediaType("foo").match(headers.MediaType("foo"))
assert headers.MediaType("foo").match("foo")
assert not headers.MediaType("foo").match(headers.MediaType("*"))
assert not headers.MediaType("foo").match("*")
assert not headers.MediaType("foo").match(headers.MediaType("bar"))
assert not headers.MediaType("foo").match("bar")
@pytest.mark.parametrize(
"value,other,outcome",
"value,other,outcome,allow_type,allow_subtype",
(
# ALLOW BOTH
("foo/bar", "foo/bar", True),
("foo/bar", headers.Matched.parse("foo/bar"), True),
("foo/bar", "foo/*", True),
("foo/bar", headers.Matched.parse("foo/*"), True),
("foo/bar", "*/*", True),
("foo/bar", headers.Matched.parse("*/*"), True),
("foo/*", "foo/bar", True),
("foo/*", headers.Matched.parse("foo/bar"), True),
("foo/*", "foo/*", True),
("foo/*", headers.Matched.parse("foo/*"), True),
("foo/*", "*/*", True),
("foo/*", headers.Matched.parse("*/*"), True),
("*/*", "foo/bar", True),
("*/*", headers.Matched.parse("foo/bar"), True),
("*/*", "foo/*", True),
("*/*", headers.Matched.parse("foo/*"), True),
("*/*", "*/*", True),
("*/*", headers.Matched.parse("*/*"), True),
("foo/bar", "foo/bar", True, True, True),
("foo/bar", headers.Accept.parse("foo/bar"), True, True, True),
("foo/bar", "foo/*", True, True, True),
("foo/bar", headers.Accept.parse("foo/*"), True, True, True),
("foo/bar", "*/*", True, True, True),
("foo/bar", headers.Accept.parse("*/*"), True, True, True),
("foo/*", "foo/bar", True, True, True),
("foo/*", headers.Accept.parse("foo/bar"), True, True, True),
("foo/*", "foo/*", True, True, True),
("foo/*", headers.Accept.parse("foo/*"), True, True, True),
("foo/*", "*/*", True, True, True),
("foo/*", headers.Accept.parse("*/*"), True, True, True),
("*/*", "foo/bar", True, True, True),
("*/*", headers.Accept.parse("foo/bar"), True, True, True),
("*/*", "foo/*", True, True, True),
("*/*", headers.Accept.parse("foo/*"), True, True, True),
("*/*", "*/*", True, True, True),
("*/*", headers.Accept.parse("*/*"), True, True, True),
# ALLOW TYPE
("foo/bar", "foo/bar", True, True, False),
("foo/bar", headers.Accept.parse("foo/bar"), True, True, False),
("foo/bar", "foo/*", False, True, False),
("foo/bar", headers.Accept.parse("foo/*"), False, True, False),
("foo/bar", "*/*", False, True, False),
("foo/bar", headers.Accept.parse("*/*"), False, True, False),
("foo/*", "foo/bar", False, True, False),
("foo/*", headers.Accept.parse("foo/bar"), False, True, False),
("foo/*", "foo/*", False, True, False),
("foo/*", headers.Accept.parse("foo/*"), False, True, False),
("foo/*", "*/*", False, True, False),
("foo/*", headers.Accept.parse("*/*"), False, True, False),
("*/*", "foo/bar", False, True, False),
("*/*", headers.Accept.parse("foo/bar"), False, True, False),
("*/*", "foo/*", False, True, False),
("*/*", headers.Accept.parse("foo/*"), False, True, False),
("*/*", "*/*", False, True, False),
("*/*", headers.Accept.parse("*/*"), False, True, False),
# ALLOW SUBTYPE
("foo/bar", "foo/bar", True, False, True),
("foo/bar", headers.Accept.parse("foo/bar"), True, False, True),
("foo/bar", "foo/*", True, False, True),
("foo/bar", headers.Accept.parse("foo/*"), True, False, True),
("foo/bar", "*/*", False, False, True),
("foo/bar", headers.Accept.parse("*/*"), False, False, True),
("foo/*", "foo/bar", True, False, True),
("foo/*", headers.Accept.parse("foo/bar"), True, False, True),
("foo/*", "foo/*", True, False, True),
("foo/*", headers.Accept.parse("foo/*"), True, False, True),
("foo/*", "*/*", False, False, True),
("foo/*", headers.Accept.parse("*/*"), False, False, True),
("*/*", "foo/bar", False, False, True),
("*/*", headers.Accept.parse("foo/bar"), False, False, True),
("*/*", "foo/*", False, False, True),
("*/*", headers.Accept.parse("foo/*"), False, False, True),
("*/*", "*/*", False, False, True),
("*/*", headers.Accept.parse("*/*"), False, False, True),
),
)
def test_accept_matching(value, other, outcome):
assert bool(headers.Matched.parse(value).match(other)) is outcome
def test_accept_matching(value, other, outcome, allow_type, allow_subtype):
assert (
headers.Accept.parse(value).match(
other,
allow_type_wildcard=allow_type,
allow_subtype_wildcard=allow_subtype,
)
is outcome
)
@pytest.mark.parametrize("value", ("foo/bar", "foo/*", "*/*"))
def test_value_in_accept(value):
acceptable = headers.parse_accept(value)
assert acceptable.match("foo/bar")
assert acceptable.match("foo/*")
assert acceptable.match("*/*")
assert "foo/bar" in acceptable
assert "foo/*" in acceptable
assert "*/*" in acceptable
@pytest.mark.parametrize("value", ("foo/bar", "foo/*"))
def test_value_not_in_accept(value):
acceptable = headers.parse_accept(value)
assert not acceptable.match("no/match")
assert not acceptable.match("no/*")
assert "*/*" not in acceptable
assert "*/bar" not in acceptable
assert "no/match" not in acceptable
assert "no/*" not in acceptable
@pytest.mark.parametrize(
@@ -310,160 +365,6 @@ def test_value_not_in_accept(value):
),
),
)
def test_browser_headers_general(header, expected):
def test_browser_headers(header, expected):
request = Request(b"/", {"accept": header}, "1.1", "GET", None, None)
assert [str(item) for item in request.accept] == expected
@pytest.mark.parametrize(
"header,expected",
(
(
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", # noqa: E501
[
("text/html", 1.0),
("application/xhtml+xml", 1.0),
("image/avif", 1.0),
("image/webp", 1.0),
("application/xml", 0.9),
("*/*", 0.8),
],
),
),
)
def test_browser_headers_specific(header, expected):
mimes = [e[0] for e in expected]
qs = [e[1] for e in expected]
request = Request(b"/", {"accept": header}, "1.1", "GET", None, None)
assert request.accept == mimes
for a, m, q in zip(request.accept, mimes, qs):
assert a == m
assert a.mime == m
assert a.q == q
@pytest.mark.parametrize(
"raw",
(
"text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8",
"application/xml;q=0.9, */*;q=0.8, text/html, application/xhtml+xml",
(
"foo/bar;q=0.9, */*;q=0.8, text/html=0.8, "
"text/plain, application/xhtml+xml"
),
),
)
def test_accept_ordering(raw):
"""Should sort by q but also be stable."""
accept = headers.parse_accept(raw)
assert accept[0].type == "text"
raw1 = ", ".join(str(a) for a in accept)
accept = headers.parse_accept(raw1)
raw2 = ", ".join(str(a) for a in accept)
assert raw1 == raw2
def test_not_accept_wildcard():
accept = headers.parse_accept("*/*, foo/*, */bar, foo/bar;q=0.1")
assert not accept.match(
"text/html", "foo/foo", "bar/bar", accept_wildcards=False
)
# Should ignore wildcards in accept but still matches them from mimes
m = accept.match("text/plain", "*/*", accept_wildcards=False)
assert m.mime == "*/*"
assert m.match("*/*")
assert m.header == "foo/bar"
assert not accept.match(
"text/html", "foo/foo", "bar/bar", accept_wildcards=False
)
def test_accept_misc():
header = (
"foo/bar;q=0.0, */plain;param=123, text/plain, text/*, foo/bar;q=0.5"
)
a = headers.parse_accept(header)
assert repr(a) == (
"[*/plain;param=123, text/plain, text/*, "
"foo/bar;q=0.5, foo/bar;q=0.0]"
) # noqa: E501
assert str(a) == (
"*/plain;param=123, text/plain, text/*, "
"foo/bar;q=0.5, foo/bar;q=0.0"
) # noqa: E501
# q=1 types don't match foo/bar but match the two others,
# text/* comes first and matches */plain because it
# comes first in the header
m = a.match("foo/bar", "text/*", "text/plain")
assert repr(m) == "<text/* matched */plain;param=123>"
assert m == "text/*"
assert m.mime == "text/*"
assert m.header.mime == "*/plain"
assert m.header.type == "*"
assert m.header.subtype == "plain"
assert m.header.q == 1.0
assert m.header.params == dict(param="123")
# Matches object against another Matched object (by mime and header)
assert m == a.match("text/*")
# Against unsupported type falls back to object id matching
assert m != 123
# Matches the highest q value
m = a.match("foo/bar")
assert repr(m) == "<foo/bar matched foo/bar;q=0.5>"
assert m == "foo/bar"
assert m == "foo/bar;q=0.5"
# Matching nothing special case
m = a.match()
assert m == ""
assert m.header is None
# No header means anything
a = headers.parse_accept(None)
assert a == ["*/*"]
assert a.match("foo/bar")
# Empty header means nothing
a = headers.parse_accept("")
assert a == []
assert not a.match("foo/bar")
@pytest.mark.parametrize(
"headers,expected",
(
({"foo": "bar"}, "bar"),
((("foo", "bar"), ("foo", "baz")), "bar,baz"),
({}, ""),
),
)
def test_field_simple_accessor(headers, expected):
request = make_request(headers)
assert request.headers.foo == request.headers.foo_ == expected
@pytest.mark.parametrize(
"headers,expected",
(
({"foo-bar": "bar"}, "bar"),
((("foo-bar", "bar"), ("foo-bar", "baz")), "bar,baz"),
),
)
def test_field_hyphenated_accessor(headers, expected):
request = make_request(headers)
assert request.headers.foo_bar == request.headers.foo_bar_ == expected
def test_bad_accessor():
request = make_request({})
msg = "'Header' object has no attribute '_foo'"
with pytest.raises(AttributeError, match=msg):
request.headers._foo
def test_multiple_fields_accessor(app: Sanic):
@app.get("")
async def handler(request: Request):
return json({"field": request.headers.example_field})
_, response = app.test_client.get(
"/", headers=(("Example-Field", "Foo, Bar"), ("Example-Field", "Baz"))
)
assert response.json["field"] == "Foo, Bar,Baz"
assert request.accept == expected

View File

@@ -98,17 +98,3 @@ def test_transfer_chunked(client):
data = stdjson.loads(body)
assert data == ["foo", "bar"]
def test_url_encoding(client):
client.send(
"""
GET /invalid\xA0url HTTP/1.1
"""
)
response = client.recv()
headers, body = response.rsplit(b"\r\n\r\n", 1)
assert b"400 Bad Request" in headers
assert b"URL may only contain US-ASCII characters." in body

View File

@@ -1,54 +0,0 @@
import pytest
from sanic import Sanic, text
@pytest.fixture
def late_app(app: Sanic):
app.config.TOUCHUP = False
app.get("/")(lambda _: text(""))
return app
def test_late_route(late_app: Sanic):
@late_app.before_server_start
async def late(app: Sanic):
@app.get("/late")
def handler(_):
return text("late")
_, response = late_app.test_client.get("/late")
assert response.status_code == 200
assert response.text == "late"
def test_late_middleware(late_app: Sanic):
@late_app.get("/late")
def handler(request):
return text(request.ctx.late)
@late_app.before_server_start
async def late(app: Sanic):
@app.on_request
def handler(request):
request.ctx.late = "late"
_, response = late_app.test_client.get("/late")
assert response.status_code == 200
assert response.text == "late"
def test_late_signal(late_app: Sanic):
@late_app.get("/late")
def handler(request):
return text(request.ctx.late)
@late_app.before_server_start
async def late(app: Sanic):
@app.signal("http.lifecycle.request")
def handler(request):
request.ctx.late = "late"
_, response = late_app.test_client.get("/late")
assert response.status_code == 200
assert response.text == "late"

View File

@@ -49,6 +49,96 @@ def test_multiprocessing(app):
assert len(process_list) == num_workers + 1
@pytest.mark.skipif(
not hasattr(signal, "SIGALRM"),
reason="SIGALRM is not implemented for this platform, we have to come "
"up with another timeout strategy to test these",
)
def test_multiprocessing_legacy(app):
"""Tests that the number of children we produce is correct"""
# Selects a number at random so we can spot check
num_workers = random.choice(range(2, multiprocessing.cpu_count() * 2 + 1))
process_list = set()
@app.after_server_start
async def shutdown(app):
await sleep(2.1)
app.stop()
def stop_on_alarm(*args):
for process in multiprocessing.active_children():
process_list.add(process.pid)
signal.signal(signal.SIGALRM, stop_on_alarm)
signal.alarm(2)
app.run(HOST, 4121, workers=num_workers, debug=True, legacy=True)
assert len(process_list) == num_workers
@pytest.mark.skipif(
not hasattr(signal, "SIGALRM"),
reason="SIGALRM is not implemented for this platform, we have to come "
"up with another timeout strategy to test these",
)
def test_multiprocessing_legacy_sock(app):
"""Tests that the number of children we produce is correct"""
# Selects a number at random so we can spot check
num_workers = random.choice(range(2, multiprocessing.cpu_count() * 2 + 1))
process_list = set()
@app.after_server_start
async def shutdown(app):
await sleep(2.1)
app.stop()
def stop_on_alarm(*args):
for process in multiprocessing.active_children():
process_list.add(process.pid)
signal.signal(signal.SIGALRM, stop_on_alarm)
signal.alarm(2)
sock = configure_socket(
{
"host": HOST,
"port": 4121,
"unix": None,
"backlog": 100,
}
)
app.run(workers=num_workers, debug=True, legacy=True, sock=sock)
sock.close()
assert len(process_list) == num_workers
@pytest.mark.skipif(
not hasattr(signal, "SIGALRM"),
reason="SIGALRM is not implemented for this platform, we have to come "
"up with another timeout strategy to test these",
)
def test_multiprocessing_legacy_unix(app):
"""Tests that the number of children we produce is correct"""
# Selects a number at random so we can spot check
num_workers = random.choice(range(2, multiprocessing.cpu_count() * 2 + 1))
process_list = set()
@app.after_server_start
async def shutdown(app):
await sleep(2.1)
app.stop()
def stop_on_alarm(*args):
for process in multiprocessing.active_children():
process_list.add(process.pid)
signal.signal(signal.SIGALRM, stop_on_alarm)
signal.alarm(2)
app.run(workers=num_workers, debug=True, legacy=True, unix="./test.sock")
assert len(process_list) == num_workers
@pytest.mark.skipif(
not hasattr(signal, "SIGALRM"),
reason="SIGALRM is not implemented for this platform",

View File

@@ -1,5 +1,3 @@
import uuid
from unittest.mock import Mock
from uuid import UUID, uuid4
@@ -7,7 +5,7 @@ import pytest
from sanic import Sanic, response
from sanic.exceptions import BadURL, SanicException
from sanic.request import Request
from sanic.request import Request, uuid
from sanic.server import HttpProtocol
@@ -152,47 +150,33 @@ def test_request_accept():
async def get(request):
return response.empty()
header_value = "text/plain;format=flowed, text/plain, text/*, */*"
request, _ = app.test_client.get(
"/",
headers={"Accept": header_value},
headers={
"Accept": "text/*, text/plain, text/plain;format=flowed, */*"
},
)
assert str(request.accept) == header_value
match = request.accept.match(
"*/*;format=flowed",
assert request.accept == [
"text/plain;format=flowed",
"text/plain",
"text/*",
"*/*",
)
assert match == "*/*;format=flowed"
assert match.header.mime == "text/plain"
assert match.header.params == {"format": "flowed"}
]
header_value = (
"text/plain; q=0.5, text/html, text/x-dvi; q=0.8, text/x-c"
)
request, _ = app.test_client.get(
"/",
headers={"Accept": header_value},
headers={
"Accept": (
"text/plain; q=0.5, text/html, text/x-dvi; q=0.8, text/x-c"
)
},
)
assert [str(i) for i in request.accept] == [
assert request.accept == [
"text/html",
"text/x-c",
"text/x-dvi;q=0.8",
"text/plain;q=0.5",
"text/x-dvi; q=0.8",
"text/plain; q=0.5",
]
match = request.accept.match(
"application/json",
"text/plain", # Has lower q in accept header
"text/html;format=flowed", # Params mismatch
"text/*", # Matches
"*/*",
)
assert match == "text/*"
assert match.header.mime == "text/html"
assert match.header.q == 1.0
assert not match.header.params
def test_bad_url_parse():

View File

@@ -16,9 +16,8 @@ from sanic_testing.testing import (
)
from sanic import Blueprint, Sanic
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.exceptions import ServerError
from sanic.request import RequestParameters
from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, RequestParameters
from sanic.response import html, json, text
@@ -105,11 +104,11 @@ def test_html(app):
return html("<h1>Hello</h1>")
@app.route("/foo")
async def handler_foo(request):
async def handler(request):
return html(Foo())
@app.route("/bar")
async def handler_bar(request):
async def handler(request):
return html(Bar())
request, response = app.test_client.get("/")
@@ -1814,8 +1813,8 @@ def test_request_cookies(app):
request, response = app.test_client.get("/", cookies=cookies)
assert len(request.cookies) == len(cookies)
assert request.cookies["test"] == cookies["test"]
assert request.cookies == cookies
assert request.cookies == cookies # For request._cookies
@pytest.mark.asyncio
@@ -1828,8 +1827,8 @@ async def test_request_cookies_asgi(app):
request, response = await app.asgi_client.get("/", cookies=cookies)
assert len(request.cookies) == len(cookies)
assert request.cookies["test"] == cookies["test"]
assert request.cookies == cookies
assert request.cookies == cookies # For request._cookies
def test_request_cookies_without_cookies(app):
@@ -2199,25 +2198,10 @@ def test_safe_method_with_body(app):
assert response.body == b"OK"
@pytest.mark.asyncio
async def test_conflicting_body_methods_overload_error(app: Sanic):
def test_conflicting_body_methods_overload(app):
@app.put("/")
@app.put("/p/")
@app.put("/p/<foo>")
async def put(request, foo=None):
...
with pytest.raises(
ServerError,
match="Duplicate route names detected: test_conflicting_body_methods_overload_error\.put.*",
):
await app._startup()
def test_conflicting_body_methods_overload(app: Sanic):
@app.put("/", name="one")
@app.put("/p/", name="two")
@app.put("/p/<foo>", name="three")
async def put(request, foo=None):
return json(
{"name": request.route.name, "body": str(request.body), "foo": foo}
@@ -2235,21 +2219,21 @@ def test_conflicting_body_methods_overload(app: Sanic):
_, response = app.test_client.put("/", json=payload)
assert response.status == 200
assert response.json == {
"name": "test_conflicting_body_methods_overload.one",
"name": "test_conflicting_body_methods_overload.put",
"foo": None,
"body": data,
}
_, response = app.test_client.put("/p", json=payload)
assert response.status == 200
assert response.json == {
"name": "test_conflicting_body_methods_overload.two",
"name": "test_conflicting_body_methods_overload.put",
"foo": None,
"body": data,
}
_, response = app.test_client.put("/p/test", json=payload)
assert response.status == 200
assert response.json == {
"name": "test_conflicting_body_methods_overload.three",
"name": "test_conflicting_body_methods_overload.put",
"foo": "test",
"body": data,
}
@@ -2262,26 +2246,9 @@ def test_conflicting_body_methods_overload(app: Sanic):
}
@pytest.mark.asyncio
async def test_handler_overload_error(app: Sanic):
def test_handler_overload(app):
@app.get("/long/sub/route/param_a/<param_a:str>/param_b/<param_b:str>")
@app.post("/long/sub/route/")
def handler(request, **kwargs):
...
with pytest.raises(
ServerError,
match="Duplicate route names detected: test_handler_overload_error\.handler.*",
):
await app._startup()
def test_handler_overload(app: Sanic):
@app.get(
"/long/sub/route/param_a/<param_a:str>/param_b/<param_b:str>",
name="one",
)
@app.post("/long/sub/route/", name="two")
def handler(request, **kwargs):
return json(kwargs)

View File

@@ -514,6 +514,7 @@ def test_file_stream_head_response(
def test_file_stream_response_range(
app: Sanic, file_name, static_file_directory, size, start, end
):
Range = namedtuple("Range", ["size", "start", "end", "total"])
total = len(get_file_content(static_file_directory, file_name))
range = Range(size=size, start=start, end=end, total=total)

View File

@@ -1,55 +0,0 @@
from datetime import datetime, timezone
from logging import INFO
import pytest
from sanic.compat import Header
from sanic.response.convenience import validate_file
@pytest.mark.parametrize(
"ifmod,lastmod,expected",
(
("Sat, 01 Apr 2023 00:00:00 GMT", 1672524000, None),
(
"Sat, 01 Apr 2023 00:00:00",
1672524000,
"converting if_modified_since",
),
(
"Sat, 01 Apr 2023 00:00:00 GMT",
datetime(2023, 1, 1, 0, 0, 0),
"converting last_modified",
),
(
"Sat, 01 Apr 2023 00:00:00",
datetime(2023, 1, 1, 0, 0, 0),
None,
),
(
"Sat, 01 Apr 2023 00:00:00 GMT",
datetime(2023, 1, 1, 0, 0, 0).replace(tzinfo=timezone.utc),
None,
),
(
"Sat, 01 Apr 2023 00:00:00",
datetime(2023, 1, 1, 0, 0, 0).replace(tzinfo=timezone.utc),
"converting if_modified_since",
),
),
)
@pytest.mark.asyncio
async def test_file_timestamp_validation(
lastmod, ifmod, expected, caplog: pytest.LogCaptureFixture
):
headers = Header([["If-Modified-Since", ifmod]])
with caplog.at_level(INFO):
response = await validate_file(headers, lastmod)
assert response.status == 304
records = caplog.records
if not expected:
assert len(records) == 0
else:
record = records[0]
assert expected in record.message

View File

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

View File

@@ -12,7 +12,7 @@ from sanic_testing.testing import SanicTestClient
from sanic import Blueprint, Sanic
from sanic.constants import HTTP_METHODS
from sanic.exceptions import NotFound, SanicException, ServerError
from sanic.exceptions import NotFound, SanicException
from sanic.request import Request
from sanic.response import empty, json, text
@@ -722,6 +722,7 @@ def test_add_webscoket_route_with_version(app):
def test_route_duplicate(app):
with pytest.raises(RouteExists):
@app.route("/test")
@@ -744,8 +745,8 @@ def test_route_duplicate(app):
def test_double_stack_route(app):
@app.route("/test/1", name="test1")
@app.route("/test/2", name="test2")
@app.route("/test/1")
@app.route("/test/2")
async def handler1(request):
return text("OK")
@@ -759,8 +760,8 @@ def test_double_stack_route(app):
async def test_websocket_route_asgi(app):
ev = asyncio.Event()
@app.websocket("/test/1", name="test1")
@app.websocket("/test/2", name="test2")
@app.websocket("/test/1")
@app.websocket("/test/2")
async def handler(request, ws):
ev.set()
@@ -818,6 +819,7 @@ def test_unquote_add_route(app, unquote):
def test_dynamic_add_route(app):
results = []
async def handler(request, name):
@@ -832,6 +834,7 @@ def test_dynamic_add_route(app):
def test_dynamic_add_route_string(app):
results = []
async def handler(request, name):
@@ -935,6 +938,7 @@ def test_dynamic_add_route_unhashable(app):
def test_add_route_duplicate(app):
with pytest.raises(RouteExists):
async def handler1(request):
@@ -1116,6 +1120,7 @@ def test_route_raise_ParameterNameConflicts(app):
def test_route_invalid_host(app):
host = 321
with pytest.raises(ValueError) as excinfo:
@@ -1279,7 +1284,7 @@ async def test_added_callable_route_ctx_kwargs(app):
@pytest.mark.asyncio
async def test_duplicate_route_error(app):
async def test_duplicate_route_deprecation(app):
@app.route("/foo", name="duped")
async def handler_foo(request):
return text("...")
@@ -1289,7 +1294,9 @@ async def test_duplicate_route_error(app):
return text("...")
message = (
"Duplicate route names detected: test_duplicate_route_error.duped."
r"\[DEPRECATION v23\.3\] Duplicate route names detected: "
r"test_duplicate_route_deprecation\.duped\. In the future, "
r"Sanic will enforce uniqueness in route naming\."
)
with pytest.raises(ServerError, match=message):
with pytest.warns(DeprecationWarning, match=message):
await app._startup()

View File

@@ -66,8 +66,8 @@ def test_no_register_system_signals_fails(app):
app.listener("after_server_stop")(after)
message = (
r"Cannot run Sanic\.serve with register_sys_signals=False\. Use "
r"Sanic.serve_single\."
"Cannot run Sanic.serve with register_sys_signals=False. Use "
"either Sanic.serve_single or Sanic.serve_legacy."
)
with pytest.raises(RuntimeError, match=message):
app.prepare(HOST, PORT, register_sys_signals=False)
@@ -93,7 +93,6 @@ def test_dont_register_system_signals(app):
@pytest.mark.skipif(os.name == "nt", reason="windows cannot SIGINT processes")
def test_windows_workaround():
"""Test Windows workaround (on any other OS)"""
# At least some code coverage, even though this test doesn't work on
# Windows...
class MockApp:

View File

@@ -1,3 +1,4 @@
import inspect
import logging
import os
import sys
@@ -9,7 +10,16 @@ from time import gmtime, strftime
import pytest
from sanic import Sanic, text
from sanic.exceptions import FileNotFound, ServerError
from sanic.exceptions import FileNotFound
@pytest.fixture(scope="module")
def static_file_directory():
"""The static directory to serve"""
current_file = inspect.getfile(inspect.currentframe())
current_directory = os.path.dirname(os.path.abspath(current_file))
static_directory = os.path.join(current_directory, "static")
return static_directory
@pytest.fixture(scope="module")
@@ -108,9 +118,9 @@ def test_static_file_pathlib(app, static_file_directory, file_name):
def test_static_file_bytes(app, static_file_directory, file_name):
bsep = os.path.sep.encode("utf-8")
file_path = static_file_directory.encode("utf-8") + bsep + file_name
message = "Static file or directory must be a path-like object or string"
with pytest.raises(TypeError, match=message):
app.static("/testing.file", file_path)
app.static("/testing.file", file_path)
request, response = app.test_client.get("/testing.file")
assert response.status == 200
@pytest.mark.parametrize(
@@ -421,6 +431,7 @@ def test_static_stream_large_file(
"file_name", ["test.file", "decode me.txt", "python.png"]
)
def test_use_modified_since(app, static_file_directory, file_name):
file_stat = os.stat(get_file_path(static_file_directory, file_name))
modified_since = strftime(
"%a, %d %b %Y %H:%M:%S GMT", gmtime(file_stat.st_mtime)
@@ -518,26 +529,10 @@ def test_no_stack_trace_on_not_found(app, static_file_directory, caplog):
assert response.text == "No file: /static/non_existing_file.file"
@pytest.mark.asyncio
async def test_multiple_statics_error(app, static_file_directory):
def test_multiple_statics(app, static_file_directory):
app.static("/file", get_file_path(static_file_directory, "test.file"))
app.static("/png", get_file_path(static_file_directory, "python.png"))
message = (
r"Duplicate route names detected: test_multiple_statics_error\.static"
)
with pytest.raises(ServerError, match=message):
await app._startup()
def test_multiple_statics(app, static_file_directory):
app.static(
"/file", get_file_path(static_file_directory, "test.file"), name="file"
)
app.static(
"/png", get_file_path(static_file_directory, "python.png"), name="png"
)
_, response = app.test_client.get("/file")
assert response.status == 200
assert response.body == get_file_content(
@@ -551,22 +546,10 @@ def test_multiple_statics(app, static_file_directory):
)
@pytest.mark.asyncio
async def test_resource_type_default_error(app, static_file_directory):
def test_resource_type_default(app, static_file_directory):
app.static("/static", static_file_directory)
app.static("/file", get_file_path(static_file_directory, "test.file"))
message = r"Duplicate route names detected: test_resource_type_default_error\.static"
with pytest.raises(ServerError, match=message):
await app._startup()
def test_resource_type_default(app, static_file_directory):
app.static("/static", static_file_directory, name="static")
app.static(
"/file", get_file_path(static_file_directory, "test.file"), name="file"
)
_, response = app.test_client.get("/static")
assert response.status == 404

View File

@@ -1,123 +0,0 @@
import os
from pathlib import Path
import pytest
from sanic import Sanic
from sanic.handlers.directory import DirectoryHandler
def get_file_path(static_file_directory, file_name):
return os.path.join(static_file_directory, file_name)
def get_file_content(static_file_directory, file_name):
"""The content of the static file to check"""
with open(get_file_path(static_file_directory, file_name), "rb") as file:
return file.read()
def test_static_directory_view(app: Sanic, static_file_directory: str):
app.static("/static", static_file_directory, directory_view=True)
_, response = app.test_client.get("/static/")
assert response.status == 200
assert response.content_type == "text/html; charset=utf-8"
assert "<title>Directory Viewer</title>" in response.text
def test_static_index_single(app: Sanic, static_file_directory: str):
app.static("/static", static_file_directory, index="test.html")
_, response = app.test_client.get("/static/")
assert response.status == 200
assert response.body == get_file_content(
static_file_directory, "test.html"
)
assert response.headers["Content-Type"] == "text/html"
def test_static_index_single_not_found(app: Sanic, static_file_directory: str):
app.static("/static", static_file_directory, index="index.html")
_, response = app.test_client.get("/static/")
assert response.status == 404
def test_static_index_multiple(app: Sanic, static_file_directory: str):
app.static(
"/static",
static_file_directory,
index=["index.html", "test.html"],
)
_, response = app.test_client.get("/static/")
assert response.status == 200
assert response.body == get_file_content(
static_file_directory, "test.html"
)
assert response.headers["Content-Type"] == "text/html"
def test_static_directory_view_and_index(
app: Sanic, static_file_directory: str
):
app.static(
"/static",
static_file_directory,
directory_view=True,
index="foo.txt",
)
_, response = app.test_client.get("/static/nested/")
assert response.status == 200
assert response.content_type == "text/html; charset=utf-8"
assert "<title>Directory Viewer</title>" in response.text
_, response = app.test_client.get("/static/nested/dir/")
assert response.status == 200
assert response.body == get_file_content(
f"{static_file_directory}/nested/dir", "foo.txt"
)
assert response.content_type == "text/plain"
def test_static_directory_handler(app: Sanic, static_file_directory: str):
dh = DirectoryHandler(
"/static",
Path(static_file_directory),
directory_view=True,
index="foo.txt",
)
app.static("/static", static_file_directory, directory_handler=dh)
_, response = app.test_client.get("/static/nested/")
assert response.status == 200
assert response.content_type == "text/html; charset=utf-8"
assert "<title>Directory Viewer</title>" in response.text
_, response = app.test_client.get("/static/nested/dir/")
assert response.status == 200
assert response.body == get_file_content(
f"{static_file_directory}/nested/dir", "foo.txt"
)
assert response.content_type == "text/plain"
def test_static_directory_handler_fails(app: Sanic):
dh = DirectoryHandler(
"/static",
Path(""),
directory_view=True,
index="foo.txt",
)
message = (
"When explicitly setting directory_handler, you cannot "
"set either directory_view or index. Instead, pass "
"these arguments to your DirectoryHandler instance."
)
with pytest.raises(ValueError, match=message):
app.static("/static", "", directory_handler=dh, directory_view=True)
with pytest.raises(ValueError, match=message):
app.static("/static", "", directory_handler=dh, index="index.html")

View File

@@ -12,7 +12,7 @@ from urllib.parse import urlparse
import pytest
from sanic_testing.testing import HOST, PORT, SanicTestClient
from sanic_testing.testing import HOST, PORT
import sanic.http.tls.creators
@@ -29,24 +29,16 @@ from sanic.http.tls.creators import (
get_ssl_context,
)
from sanic.response import text
from sanic.worker.loader import CertLoader
current_dir = os.path.dirname(os.path.realpath(__file__))
localhost_dir = os.path.join(current_dir, "certs/localhost")
password_dir = os.path.join(current_dir, "certs/password")
sanic_dir = os.path.join(current_dir, "certs/sanic.example")
invalid_dir = os.path.join(current_dir, "certs/invalid.nonexist")
localhost_cert = os.path.join(localhost_dir, "fullchain.pem")
localhost_key = os.path.join(localhost_dir, "privkey.pem")
sanic_cert = os.path.join(sanic_dir, "fullchain.pem")
sanic_key = os.path.join(sanic_dir, "privkey.pem")
password_dict = {
"cert": os.path.join(password_dir, "fullchain.pem"),
"key": os.path.join(password_dir, "privkey.pem"),
"password": "password",
"names": ["localhost"],
}
@pytest.fixture
@@ -428,29 +420,6 @@ def test_no_certs_on_list(app):
assert "No certificates" in str(excinfo.value)
def test_custom_cert_loader():
class MyCertLoader(CertLoader):
def load(self, app: Sanic):
self._ssl_data = {
"key": localhost_key,
"cert": localhost_cert,
}
return super().load(app)
app = Sanic("custom", certloader_class=MyCertLoader)
@app.get("/test")
async def handler(request):
return text("ssl test")
client = SanicTestClient(app, port=44556)
request, response = client.get("https://localhost:44556/test")
assert request.scheme == "https"
assert response.status_code == 200
assert response.text == "ssl test"
def test_logger_vhosts(caplog):
app = Sanic(name="test_logger_vhosts")
@@ -685,6 +654,7 @@ def test_sanic_ssl_context_create():
reason="This test requires fork context",
)
def test_ssl_in_multiprocess_mode(app: Sanic, caplog):
ssl_dict = {"cert": localhost_cert, "key": localhost_key}
event = Event()
@@ -708,34 +678,3 @@ def test_ssl_in_multiprocess_mode(app: Sanic, caplog):
logging.INFO,
"Goin' Fast @ https://127.0.0.1:8000",
) in caplog.record_tuples
@pytest.mark.skipif(
sys.platform not in ("linux", "darwin"),
reason="This test requires fork context",
)
def test_ssl_in_multiprocess_mode_password(
app: Sanic, caplog: pytest.LogCaptureFixture
):
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 use_context("fork"):
with caplog.at_level(logging.INFO):
app.run(ssl=password_dict)
assert event.is_set()
assert (
"sanic.root",
logging.INFO,
"Goin' Fast @ https://127.0.0.1:8000",
) in caplog.record_tuples

View File

@@ -176,6 +176,7 @@ def handler(request: Request):
async def client(app: Sanic, loop: AbstractEventLoop):
try:
transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
async with httpx.AsyncClient(transport=transport) as client:
r = await client.get("http://myhost.invalid/")

View File

@@ -83,6 +83,7 @@ def test_simple_url_for_getting_with_more_params(app, args, url):
def test_url_for_with_server_name(app):
server_name = f"{test_host}:{test_port}"
app.config.update({"SERVER_NAME": server_name})
path = "/myurl"

View File

@@ -38,6 +38,7 @@ def test_load_module_from_file_location_with_non_existing_env_variable():
LoadFileException,
match="The following environment variables are not set: MuuMilk",
):
load_module_from_file_location("${MuuMilk}")

View File

@@ -52,23 +52,34 @@ def test_cwd_in_path():
def test_input_is_dir():
loader = AppLoader(str(STATIC))
app = loader.load()
assert isinstance(app, Sanic)
message = (
"App not found.\n Please use --simple if you are passing a "
f"directory to sanic.\n eg. sanic {str(STATIC)} --simple"
)
with pytest.raises(ValueError, match=message):
loader.load()
def test_input_is_factory():
ns = SimpleNamespace(target="foo")
ns = SimpleNamespace(module="foo")
loader = AppLoader("tests.fake.server:create_app", args=ns)
app = loader.load()
assert isinstance(app, Sanic)
message = (
"Module is not a Sanic app, it is a function\n If this callable "
"returns a Sanic instance try: \nsanic foo --factory"
)
with pytest.raises(ValueError, match=message):
loader.load()
def test_input_is_module():
ns = SimpleNamespace(target="foo")
ns = SimpleNamespace(module="foo")
loader = AppLoader("tests.fake.server", args=ns)
app = loader.load()
assert isinstance(app, Sanic)
message = (
"Module is not a Sanic app, it is a module\n "
"Perhaps you meant foo:app?"
)
with pytest.raises(ValueError, match=message):
loader.load()
@pytest.mark.parametrize("creator", ("mkcert", "trustme"))

View File

@@ -72,6 +72,24 @@ def test_not_have_multiplexer_single(app: Sanic):
assert not event.is_set()
def test_not_have_multiplexer_legacy(app: Sanic):
event = Event()
@app.main_process_start
async def setup(app, _):
app.shared_ctx.event = event
@app.after_server_start
def stop(app):
if hasattr(app, "m") and isinstance(app.m, WorkerMultiplexer):
app.shared_ctx.event.set()
app.stop()
app.run(legacy=True)
assert not event.is_set()
def test_ack(worker_state: Dict[str, Any], m: WorkerMultiplexer):
worker_state["Test"] = {"foo": "bar"}
m.ack()