Skip to content

Commit

Permalink
Release 21.9.3 (#2318)
Browse files Browse the repository at this point in the history
* Allow non-conforming ErrorHandlers (#2259)

* Allow non-conforming ErrorHandlers

* Rename to legacy lookup

* Updated depnotice

* Bump version

* Fix formatting

* Remove unused import

* Fix error messages

* Add error format commit and merge conflicts

* Make HTTP connections start in IDLE stage, avoiding delays and error messages (#2268)

* Make all new connections start in IDLE stage, and switch to REQUEST stage only once any bytes are received from client. This makes new connections without any request obey keepalive timeout rather than request timeout like they currently do.

* Revert typo

* Remove request timeout endpoint test which is no longer working (still tested by mocking). Fix mock timeout test setup.

Co-authored-by: L. Karkkainen <[email protected]>

* Bump version

* Add error format from config replacement objects

* Cleanup mistaken print statement

* Cleanup reversions

* Bump version

Co-authored-by: L. Kärkkäinen <[email protected]>
Co-authored-by: L. Karkkainen <[email protected]>
  • Loading branch information
3 people authored Nov 21, 2021
1 parent f995612 commit 8673021
Show file tree
Hide file tree
Showing 14 changed files with 259 additions and 155 deletions.
2 changes: 1 addition & 1 deletion sanic/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "21.9.0"
__version__ = "21.9.3"
19 changes: 11 additions & 8 deletions sanic/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,18 +173,18 @@ def __init__(
self.asgi = False
self.auto_reload = False
self.blueprints: Dict[str, Blueprint] = {}
self.config = config or Config(
load_env=load_env, env_prefix=env_prefix
self.config: Config = config or Config(
load_env=load_env,
env_prefix=env_prefix,
app=self,
)
self.configure_logging = configure_logging
self.ctx = ctx or SimpleNamespace()
self.configure_logging: bool = configure_logging
self.ctx: Any = ctx or SimpleNamespace()
self.debug = None
self.error_handler = error_handler or ErrorHandler(
fallback=self.config.FALLBACK_ERROR_FORMAT,
)
self.error_handler: ErrorHandler = error_handler or ErrorHandler()
self.is_running = False
self.is_stopping = False
self.listeners: Dict[str, List[ListenerType]] = defaultdict(list)
self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list)
self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {}
self.named_response_middleware: Dict[str, Deque[MiddlewareType]] = {}
self.reload_dirs: Set[Path] = set()
Expand Down Expand Up @@ -1474,6 +1474,9 @@ def signalize(self):
async def _startup(self):
self.signalize()
self.finalize()
ErrorHandler.finalize(
self.error_handler, fallback=self.config.FALLBACK_ERROR_FORMAT
)
TouchUp.run(self)

async def _server_event(
Expand Down
69 changes: 57 additions & 12 deletions sanic/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

from inspect import isclass
from os import environ
from pathlib import Path
from typing import Any, Dict, Optional, Union
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
from warnings import warn

from sanic.errorpages import check_error_format
Expand All @@ -10,6 +12,10 @@
from .utils import load_module_from_file_location, str_to_bool


if TYPE_CHECKING: # no cov
from sanic import Sanic


SANIC_PREFIX = "SANIC_"
BASE_LOGO = """
Expand Down Expand Up @@ -71,11 +77,14 @@ def __init__(
load_env: Optional[Union[bool, str]] = True,
env_prefix: Optional[str] = SANIC_PREFIX,
keep_alive: Optional[bool] = None,
*,
app: Optional[Sanic] = None,
):
defaults = defaults or {}
super().__init__({**DEFAULT_CONFIG, **defaults})

self.LOGO = BASE_LOGO
self._app = app
self._LOGO = BASE_LOGO

if keep_alive is not None:
self.KEEP_ALIVE = keep_alive
Expand All @@ -97,23 +106,59 @@ def __init__(

self._configure_header_size()
self._check_error_format()
self._init = True

def __getattr__(self, attr):
try:
return self[attr]
except KeyError as ke:
raise AttributeError(f"Config has no '{ke.args[0]}'")

def __setattr__(self, attr, value):
self[attr] = value
if attr in (
"REQUEST_MAX_HEADER_SIZE",
"REQUEST_BUFFER_SIZE",
"REQUEST_MAX_SIZE",
):
self._configure_header_size()
elif attr == "FALLBACK_ERROR_FORMAT":
self._check_error_format()
def __setattr__(self, attr, value) -> None:
self.update({attr: value})

def __setitem__(self, attr, value) -> None:
self.update({attr: value})

def update(self, *other, **kwargs) -> None:
other_mapping = {k: v for item in other for k, v in dict(item).items()}
super().update(*other, **kwargs)
for attr, value in {**other_mapping, **kwargs}.items():
self._post_set(attr, value)

def _post_set(self, attr, value) -> None:
if self.get("_init"):
if attr in (
"REQUEST_MAX_HEADER_SIZE",
"REQUEST_BUFFER_SIZE",
"REQUEST_MAX_SIZE",
):
self._configure_header_size()
elif attr == "FALLBACK_ERROR_FORMAT":
self._check_error_format()
if self.app and value != self.app.error_handler.fallback:
if self.app.error_handler.fallback != "auto":
warn(
"Overriding non-default ErrorHandler fallback "
"value. Changing from "
f"{self.app.error_handler.fallback} to {value}."
)
self.app.error_handler.fallback = value
elif attr == "LOGO":
self._LOGO = value
warn(
"Setting the config.LOGO is deprecated and will no longer "
"be supported starting in v22.6.",
DeprecationWarning,
)

@property
def app(self):
return self._app

@property
def LOGO(self):
return self._LOGO

def _configure_header_size(self):
Http.set_header_max_size(
Expand Down
3 changes: 2 additions & 1 deletion sanic/errorpages.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,8 @@ def exception_response(
# from the route
if request.route:
try:
render_format = request.route.ctx.error_format
if request.route.ctx.error_format:
render_format = request.route.ctx.error_format
except AttributeError:
...

Expand Down
46 changes: 43 additions & 3 deletions sanic/handlers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from inspect import signature
from typing import Dict, List, Optional, Tuple, Type

from sanic.errorpages import BaseRenderer, HTMLRenderer, exception_response
Expand Down Expand Up @@ -25,7 +26,9 @@ class ErrorHandler:
"""

# Beginning in v22.3, the base renderer will be TextRenderer
def __init__(self, fallback: str, base: Type[BaseRenderer] = HTMLRenderer):
def __init__(
self, fallback: str = "auto", base: Type[BaseRenderer] = HTMLRenderer
):
self.handlers: List[Tuple[Type[BaseException], RouteHandler]] = []
self.cached_handlers: Dict[
Tuple[Type[BaseException], Optional[str]], Optional[RouteHandler]
Expand All @@ -34,6 +37,41 @@ def __init__(self, fallback: str, base: Type[BaseRenderer] = HTMLRenderer):
self.fallback = fallback
self.base = base

@classmethod
def finalize(cls, error_handler, fallback: Optional[str] = None):
if (
fallback
and fallback != "auto"
and error_handler.fallback == "auto"
):
error_handler.fallback = fallback

if not isinstance(error_handler, cls):
error_logger.warning(
f"Error handler is non-conforming: {type(error_handler)}"
)

sig = signature(error_handler.lookup)
if len(sig.parameters) == 1:
error_logger.warning(
DeprecationWarning(
"You are using a deprecated error handler. The lookup "
"method should accept two positional parameters: "
"(exception, route_name: Optional[str]). "
"Until you upgrade your ErrorHandler.lookup, Blueprint "
"specific exceptions will not work properly. Beginning "
"in v22.3, the legacy style lookup method will not "
"work at all."
),
)
error_handler._lookup = error_handler._legacy_lookup

def _full_lookup(self, exception, route_name: Optional[str] = None):
return self.lookup(exception, route_name)

def _legacy_lookup(self, exception, route_name: Optional[str] = None):
return self.lookup(exception)

def add(self, exception, handler, route_names: Optional[List[str]] = None):
"""
Add a new exception handler to an already existing handler object.
Expand All @@ -56,7 +94,7 @@ def add(self, exception, handler, route_names: Optional[List[str]] = None):
else:
self.cached_handlers[(exception, None)] = handler

def lookup(self, exception, route_name: Optional[str]):
def lookup(self, exception, route_name: Optional[str] = None):
"""
Lookup the existing instance of :class:`ErrorHandler` and fetch the
registered handler for a specific type of exception.
Expand Down Expand Up @@ -94,6 +132,8 @@ def lookup(self, exception, route_name: Optional[str]):
handler = None
return handler

_lookup = _full_lookup

def response(self, request, exception):
"""Fetches and executes an exception handler and returns a response
object
Expand All @@ -109,7 +149,7 @@ def response(self, request, exception):
or registered handler for that type of exception.
"""
route_name = request.name if request else None
handler = self.lookup(exception, route_name)
handler = self._lookup(exception, route_name)
response = None
try:
if handler:
Expand Down
22 changes: 8 additions & 14 deletions sanic/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ def __init__(self, protocol):
self.keep_alive = True
self.stage: Stage = Stage.IDLE
self.dispatch = self.protocol.app.dispatch
self.init_for_request()

def init_for_request(self):
"""Init/reset all per-request variables."""
Expand All @@ -129,14 +128,20 @@ async def http1(self):
"""
HTTP 1.1 connection handler
"""
while True: # As long as connection stays keep-alive
# Handle requests while the connection stays reusable
while self.keep_alive and self.stage is Stage.IDLE:
self.init_for_request()
# Wait for incoming bytes (in IDLE stage)
if not self.recv_buffer:
await self._receive_more()
self.stage = Stage.REQUEST
try:
# Receive and handle a request
self.stage = Stage.REQUEST
self.response_func = self.http1_response_header

await self.http1_request_header()

self.stage = Stage.HANDLER
self.request.conn_info = self.protocol.conn_info
await self.protocol.request_handler(self.request)

Expand Down Expand Up @@ -187,16 +192,6 @@ async def http1(self):
if self.response:
self.response.stream = None

# Exit and disconnect if no more requests can be taken
if self.stage is not Stage.IDLE or not self.keep_alive:
break

self.init_for_request()

# Wait for the next request
if not self.recv_buffer:
await self._receive_more()

async def http1_request_header(self): # no cov
"""
Receive and parse request header into self.request.
Expand Down Expand Up @@ -299,7 +294,6 @@ async def http1_request_header(self): # no cov

# Remove header and its trailing CRLF
del buf[: pos + 4]
self.stage = Stage.HANDLER
self.request, request.stream = request, self
self.protocol.state["requests_count"] += 1

Expand Down
4 changes: 2 additions & 2 deletions sanic/mixins/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -918,7 +918,7 @@ def _register_static(

return route

def _determine_error_format(self, handler) -> str:
def _determine_error_format(self, handler) -> Optional[str]:
if not isinstance(handler, CompositionView):
try:
src = dedent(getsource(handler))
Expand All @@ -930,7 +930,7 @@ def _determine_error_format(self, handler) -> str:
except (OSError, TypeError):
...

return "auto"
return None

def _get_response_types(self, node):
types = set()
Expand Down
7 changes: 3 additions & 4 deletions sanic/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,10 @@ def add( # type: ignore
route.ctx.stream = stream
route.ctx.hosts = hosts
route.ctx.static = static
route.ctx.error_format = (
error_format or self.ctx.app.config.FALLBACK_ERROR_FORMAT
)
route.ctx.error_format = error_format

check_error_format(route.ctx.error_format)
if error_format:
check_error_format(route.ctx.error_format)

routes.append(route)

Expand Down
38 changes: 38 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path
from tempfile import TemporaryDirectory
from textwrap import dedent
from unittest.mock import Mock

import pytest

Expand Down Expand Up @@ -350,3 +351,40 @@ def test_update_from_lowercase_key(app):
d = {"test_setting_value": 1}
app.update_config(d)
assert "test_setting_value" not in app.config


def test_deprecation_notice_when_setting_logo(app):
message = (
"Setting the config.LOGO is deprecated and will no longer be "
"supported starting in v22.6."
)
with pytest.warns(DeprecationWarning, match=message):
app.config.LOGO = "My Custom Logo"


def test_config_set_methods(app, monkeypatch):
post_set = Mock()
monkeypatch.setattr(Config, "_post_set", post_set)

app.config.FOO = 1
post_set.assert_called_once_with("FOO", 1)
post_set.reset_mock()

app.config["FOO"] = 2
post_set.assert_called_once_with("FOO", 2)
post_set.reset_mock()

app.config.update({"FOO": 3})
post_set.assert_called_once_with("FOO", 3)
post_set.reset_mock()

app.config.update([("FOO", 4)])
post_set.assert_called_once_with("FOO", 4)
post_set.reset_mock()

app.config.update(FOO=5)
post_set.assert_called_once_with("FOO", 5)
post_set.reset_mock()

app.config.update_config({"FOO": 6})
post_set.assert_called_once_with("FOO", 6)
Loading

0 comments on commit 8673021

Please sign in to comment.