diff --git a/sanic/app.py b/sanic/app.py index 208c5688..a411e0cf 100644 --- a/sanic/app.py +++ b/sanic/app.py @@ -84,6 +84,7 @@ class Sanic(BaseSanic): log_config: Optional[Dict[str, Any]] = None, configure_logging: bool = True, register: Optional[bool] = None, + dumps: Optional[Callable[..., str]] = None, ) -> None: super().__init__() @@ -117,8 +118,6 @@ class Sanic(BaseSanic): self.websocket_tasks: Set[Future] = set() self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {} self.named_response_middleware: Dict[str, Deque[MiddlewareType]] = {} - # self.named_request_middleware: Dict[str, MiddlewareType] = {} - # self.named_response_middleware: Dict[str, MiddlewareType] = {} self._test_manager = None self._test_client = None self._asgi_client = None @@ -133,6 +132,9 @@ class Sanic(BaseSanic): self.router.ctx.app = self + if dumps: + BaseHTTPResponse._dumps = dumps + @property def loop(self): """ diff --git a/sanic/blueprint_group.py b/sanic/blueprint_group.py index b6b1029e..c8a36534 100644 --- a/sanic/blueprint_group.py +++ b/sanic/blueprint_group.py @@ -61,7 +61,8 @@ class BlueprintGroup(MutableSequence): Create a new Blueprint Group :param url_prefix: URL: to be prefixed before all the Blueprint Prefix - :param version: API Version for the blueprint group. This will be inherited by each of the Blueprint + :param version: API Version for the blueprint group. This will be + inherited by each of the Blueprint :param strict_slashes: URL Strict slash behavior indicator """ self._blueprints = [] @@ -90,8 +91,8 @@ class BlueprintGroup(MutableSequence): @property def version(self) -> Optional[Union[str, int, float]]: """ - API Version for the Blueprint Group. This will be applied only in case if the Blueprint doesn't already have - a version specified + API Version for the Blueprint Group. This will be applied only in case + if the Blueprint doesn't already have a version specified :return: Version information """ @@ -162,7 +163,8 @@ class BlueprintGroup(MutableSequence): def _sanitize_blueprint(self, bp: "sanic.Blueprint") -> "sanic.Blueprint": """ - Sanitize the Blueprint Entity to override the Version and strict slash behaviors as required. + Sanitize the Blueprint Entity to override the Version and strict slash + behaviors as required. :param bp: Sanic Blueprint entity Object :return: Modified Blueprint diff --git a/sanic/blueprints.py b/sanic/blueprints.py index 1de4daeb..33a03993 100644 --- a/sanic/blueprints.py +++ b/sanic/blueprints.py @@ -1,16 +1,16 @@ from collections import defaultdict -from typing import Dict, List, Optional, Iterable +from typing import Dict, Iterable, List, Optional from sanic_routing.route import Route # type: ignore from sanic.base import BaseSanic from sanic.blueprint_group import BlueprintGroup +from sanic.models.futures import FutureRoute, FutureStatic from sanic.models.handler_types import ( ListenerType, MiddlewareType, RouteHandler, ) -from sanic.models.futures import FutureRoute, FutureStatic class Blueprint(BaseSanic): @@ -99,7 +99,8 @@ class Blueprint(BaseSanic): :param blueprints: blueprints to be registered as a group :param url_prefix: URL route to be prepended to all sub-prefixes :param version: API Version to be used for Blueprint group - :param strict_slashes: Indicate strict slash termination behavior for URL + :param strict_slashes: Indicate strict slash termination behavior + for URL """ def chain(nested) -> Iterable[Blueprint]: diff --git a/sanic/models/futures.py b/sanic/models/futures.py index 083b9815..8e8d702c 100644 --- a/sanic/models/futures.py +++ b/sanic/models/futures.py @@ -1,10 +1,10 @@ from pathlib import PurePath -from typing import NamedTuple, List, Union, Iterable, Optional +from typing import Iterable, List, NamedTuple, Optional, Union from sanic.models.handler_types import ( + ErrorMiddlewareType, ListenerType, MiddlewareType, - ErrorMiddlewareType, ) diff --git a/sanic/response.py b/sanic/response.py index e17b080d..a67d24fe 100644 --- a/sanic/response.py +++ b/sanic/response.py @@ -39,6 +39,8 @@ class BaseHTTPResponse: The base class for all HTTP Responses """ + _dumps = json_dumps + def __init__(self): self.asgi: bool = False self.body: Optional[bytes] = None @@ -66,8 +68,8 @@ class BaseHTTPResponse: response.cookies["test"]["domain"] = ".yummy-yummy-cookie.com" response.cookies["test"]["httponly"] = True - `See user guide - `_ + `See user guide re: cookies + `__ :return: the cookie jar :rtype: CookieJar @@ -251,7 +253,7 @@ def json( status: int = 200, headers: Optional[Dict[str, str]] = None, content_type: str = "application/json", - dumps: Callable[..., str] = json_dumps, + dumps: Optional[Callable[..., str]] = None, **kwargs, ) -> HTTPResponse: """ @@ -262,6 +264,8 @@ def json( :param headers: Custom Headers. :param kwargs: Remaining arguments that are passed to the json encoder. """ + if not dumps: + dumps = BaseHTTPResponse._dumps return HTTPResponse( dumps(body, **kwargs), headers=headers, diff --git a/tests/test_blueprint_group.py b/tests/test_blueprint_group.py index 168c84fa..7c4bbf90 100644 --- a/tests/test_blueprint_group.py +++ b/tests/test_blueprint_group.py @@ -1,8 +1,8 @@ from pytest import raises from sanic.app import Sanic -from sanic.blueprints import Blueprint from sanic.blueprint_group import BlueprintGroup +from sanic.blueprints import Blueprint from sanic.request import Request from sanic.response import HTTPResponse, text diff --git a/tests/test_json_encoding.py b/tests/test_json_encoding.py new file mode 100644 index 00000000..ab1858b9 --- /dev/null +++ b/tests/test_json_encoding.py @@ -0,0 +1,92 @@ +import sys + +from dataclasses import asdict, dataclass +from functools import partial +from json import dumps as sdumps + +import pytest + + +try: + from ujson import dumps as udumps + + NO_UJSON = False + DEFAULT_DUMPS = udumps +except ModuleNotFoundError: + NO_UJSON = True + DEFAULT_DUMPS = partial(sdumps, separators=(",", ":")) + +from sanic import Sanic +from sanic.response import BaseHTTPResponse, json + + +@dataclass +class Foo: + bar: str + + def __json__(self): + return udumps(asdict(self)) + + +@pytest.fixture +def foo(): + return Foo(bar="bar") + + +@pytest.fixture +def payload(foo): + return {"foo": foo} + + +@pytest.fixture(autouse=True) +def default_back_to_ujson(): + yield + BaseHTTPResponse._dumps = DEFAULT_DUMPS + + +def test_change_encoder(): + Sanic("...", dumps=sdumps) + assert BaseHTTPResponse._dumps == sdumps + + +def test_change_encoder_to_some_custom(): + def my_custom_encoder(): + return "foo" + + Sanic("...", dumps=my_custom_encoder) + assert BaseHTTPResponse._dumps == my_custom_encoder + + +@pytest.mark.skipif(NO_UJSON is True, reason="ujson not installed") +def test_json_response_ujson(payload): + """ujson will look at __json__""" + response = json(payload) + assert response.body == b'{"foo":{"bar":"bar"}}' + + with pytest.raises( + TypeError, match="Object of type Foo is not JSON serializable" + ): + json(payload, dumps=sdumps) + + Sanic("...", dumps=sdumps) + with pytest.raises( + TypeError, match="Object of type Foo is not JSON serializable" + ): + json(payload) + + +@pytest.mark.skipif(NO_UJSON is True, reason="ujson not installed") +def test_json_response_json(): + """One of the easiest ways to tell the difference is that ujson cannot + serialize over 64 bits""" + too_big_for_ujson = 111111111111111111111 + + with pytest.raises(OverflowError, match="int too big to convert"): + json(too_big_for_ujson) + + response = json(too_big_for_ujson, dumps=sdumps) + assert sys.getsizeof(response.body) == 54 + + Sanic("...", dumps=sdumps) + response = json(too_big_for_ujson) + assert sys.getsizeof(response.body) == 54