Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES/11283.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed access log timestamps ignoring daylight saving time (DST) changes. The
previous implementation used :py:data:`time.timezone` which is a constant and
does not reflect DST transitions -- by :user:`nightcityblade`.
7 changes: 7 additions & 0 deletions CHANGES/11989.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Added explicit APIs for bytes-returning JSON serializer:
``JSONBytesEncoder`` type, ``JsonBytesPayload``,
:func:`~aiohttp.web.json_bytes_response`,
:meth:`~aiohttp.web.WebSocketResponse.send_json_bytes` and
:meth:`~aiohttp.ClientWebSocketResponse.send_json_bytes` methods, and
``json_serialize_bytes`` parameter for :class:`~aiohttp.ClientSession`
-- by :user:`kevinpark1217`.
16 changes: 14 additions & 2 deletions aiohttp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,13 @@
from .http import WS_KEY, HttpVersion, WebSocketReader, WebSocketWriter
from .http_websocket import WSHandshakeError, ws_ext_gen, ws_ext_parse
from .tracing import Trace, TraceConfig
from .typedefs import JSONEncoder, LooseCookies, LooseHeaders, StrOrURL
from .typedefs import (
JSONBytesEncoder,
JSONEncoder,
LooseCookies,
LooseHeaders,
StrOrURL,
)

__all__ = (
# client_exceptions
Expand Down Expand Up @@ -278,6 +284,7 @@ class ClientSession:
"_default_auth",
"_version",
"_json_serialize",
"_json_serialize_bytes",
"_requote_redirect_url",
"_timeout",
"_raise_for_status",
Expand Down Expand Up @@ -312,6 +319,7 @@ def __init__(
skip_auto_headers: Iterable[str] | None = None,
auth: BasicAuth | None = None,
json_serialize: JSONEncoder = json.dumps,
json_serialize_bytes: JSONBytesEncoder | None = None,
request_class: type[ClientRequest] = ClientRequest,
response_class: type[ClientResponse] = ClientResponse,
ws_response_class: type[ClientWebSocketResponse] = ClientWebSocketResponse,
Expand Down Expand Up @@ -390,6 +398,7 @@ def __init__(
self._default_auth = auth
self._version = version
self._json_serialize = json_serialize
self._json_serialize_bytes = json_serialize_bytes
self._raise_for_status = raise_for_status
self._auto_decompress = auto_decompress
self._trust_env = trust_env
Expand Down Expand Up @@ -518,7 +527,10 @@ async def _request(
"data and json parameters can not be used at the same time"
)
elif json is not None:
data = payload.JsonPayload(json, dumps=self._json_serialize)
if self._json_serialize_bytes is not None:
data = payload.JsonBytesPayload(json, dumps=self._json_serialize_bytes)
else:
data = payload.JsonPayload(json, dumps=self._json_serialize)

redirects = 0
history: list[ClientResponse] = []
Expand Down
15 changes: 15 additions & 0 deletions aiohttp/client_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from .typedefs import (
DEFAULT_JSON_DECODER,
DEFAULT_JSON_ENCODER,
JSONBytesEncoder,
JSONDecoder,
JSONEncoder,
)
Expand Down Expand Up @@ -302,6 +303,20 @@ async def send_json(
) -> None:
await self.send_str(dumps(data), compress=compress)

async def send_json_bytes(
self,
data: Any,
compress: int | None = None,
*,
dumps: JSONBytesEncoder,
) -> None:
"""Send JSON data using a bytes-returning encoder as a binary frame.

Use this when your JSON encoder (like orjson) returns bytes
instead of str, avoiding the encode/decode overhead.
"""
await self.send_bytes(dumps(data), compress=compress)

async def close(self, *, code: int = WSCloseCode.OK, message: bytes = b"") -> bool:
# we need to break `receive()` cycle first,
# `close()` may be called from different task
Expand Down
26 changes: 25 additions & 1 deletion aiohttp/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
sentinel,
)
from .streams import StreamReader
from .typedefs import JSONEncoder
from .typedefs import JSONBytesEncoder, JSONEncoder

__all__ = (
"PAYLOAD_REGISTRY",
Expand All @@ -38,6 +38,7 @@
"TextIOPayload",
"StringIOPayload",
"JsonPayload",
"JsonBytesPayload",
"AsyncIterablePayload",
)

Expand Down Expand Up @@ -939,6 +940,29 @@ def __init__(
)


class JsonBytesPayload(BytesPayload):
"""JSON payload for encoders that return bytes directly.

Use this when your JSON encoder (like orjson) returns bytes
instead of str, avoiding the encode/decode overhead.
"""

def __init__(
self,
value: Any,
dumps: JSONBytesEncoder,
content_type: str = "application/json",
*args: Any,
**kwargs: Any,
) -> None:
super().__init__(
dumps(value),
content_type=content_type,
*args,
**kwargs,
)


class AsyncIterablePayload(Payload):
_iter: AsyncIterator[bytes] | None = None
_value: AsyncIterable[bytes]
Expand Down
1 change: 1 addition & 0 deletions aiohttp/typedefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

Byteish = bytes | bytearray | memoryview
JSONEncoder = Callable[[Any], str]
JSONBytesEncoder = Callable[[Any], bytes]
JSONDecoder = Callable[[str], Any]
LooseHeaders = (
Mapping[str, str]
Expand Down
9 changes: 8 additions & 1 deletion aiohttp/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,13 @@
from .web_middlewares import middleware, normalize_path_middleware
from .web_protocol import PayloadAccessError, RequestHandler, RequestPayloadError
from .web_request import BaseRequest, FileField, Request
from .web_response import ContentCoding, Response, StreamResponse, json_response
from .web_response import (
ContentCoding,
Response,
StreamResponse,
json_bytes_response,
json_response,
)
from .web_routedef import (
AbstractRouteDef,
RouteDef,
Expand Down Expand Up @@ -208,6 +214,7 @@
"ContentCoding",
"Response",
"StreamResponse",
"json_bytes_response",
"json_response",
"ResponseKey",
# web_routedef
Expand Down
24 changes: 21 additions & 3 deletions aiohttp/web_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import re
import time as time_mod
from collections import namedtuple
from typing import Any, Callable, Dict, Iterable, List, Tuple # noqa
from collections.abc import Iterable
from typing import Callable, ClassVar

from .abc import AbstractAccessLogger
from .web_request import BaseRequest
Expand Down Expand Up @@ -60,6 +61,9 @@ class AccessLogger(AbstractAccessLogger):
CLEANUP_RE = re.compile(r"(%[^s])")
_FORMAT_CACHE: dict[str, tuple[str, list[KeyMethod]]] = {}

_cached_tz: ClassVar[datetime.timezone | None] = None
_cached_tz_expires: ClassVar[float] = 0.0

def __init__(self, logger: logging.Logger, log_format: str = LOG_FORMAT) -> None:
"""Initialise the logger.

Expand Down Expand Up @@ -136,10 +140,24 @@ def _format_a(request: BaseRequest, response: StreamResponse, time: float) -> st
ip = request.remote
return ip if ip is not None else "-"

@classmethod
def _get_local_time(cls) -> datetime.datetime:
if cls._cached_tz is None or time_mod.time() >= cls._cached_tz_expires:
gmtoff = time_mod.localtime().tm_gmtoff
cls._cached_tz = tz = datetime.timezone(datetime.timedelta(seconds=gmtoff))

now = datetime.datetime.now(tz)
# Expire at every 30 mins, as any DST change should occur at 0/30 mins past.
d = now + datetime.timedelta(minutes=30)
d = d.replace(minute=30 if d.minute >= 30 else 0, second=0, microsecond=0)
cls._cached_tz_expires = d.timestamp()
return now

return datetime.datetime.now(cls._cached_tz)

@staticmethod
def _format_t(request: BaseRequest, response: StreamResponse, time: float) -> str:
tz = datetime.timezone(datetime.timedelta(seconds=-time_mod.timezone))
now = datetime.datetime.now(tz)
now = AccessLogger._get_local_time()
start_time = now - datetime.timedelta(seconds=time)
return start_time.strftime("[%d/%b/%Y:%H:%M:%S %z]")

Expand Down
39 changes: 37 additions & 2 deletions aiohttp/web_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@
)
from .http import SERVER_SOFTWARE, HttpVersion10, HttpVersion11
from .payload import Payload
from .typedefs import JSONEncoder, LooseHeaders
from .typedefs import JSONBytesEncoder, JSONEncoder, LooseHeaders

REASON_PHRASES = {http_status.value: http_status.phrase for http_status in HTTPStatus}
LARGE_BODY_SIZE = 1024**2

__all__ = ("ContentCoding", "StreamResponse", "Response", "json_response")
__all__ = (
"ContentCoding",
"StreamResponse",
"Response",
"json_response",
"json_bytes_response",
)


if TYPE_CHECKING:
Expand Down Expand Up @@ -758,3 +764,32 @@ def json_response(
headers=headers,
content_type=content_type,
)


def json_bytes_response(
data: Any = sentinel,
*,
dumps: JSONBytesEncoder,
body: bytes | None = None,
status: int = 200,
reason: str | None = None,
headers: LooseHeaders | None = None,
content_type: str = "application/json",
) -> Response:
"""Create a JSON response using a bytes-returning encoder.

Use this when your JSON encoder (like orjson) returns bytes
instead of str, avoiding the encode/decode overhead.
"""
if data is not sentinel:
if body is not None:
raise ValueError("only one of data or body should be specified")
else:
body = dumps(data)
return Response(
body=body,
status=status,
reason=reason,
headers=headers,
content_type=content_type,
)
16 changes: 15 additions & 1 deletion aiohttp/web_ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from .http_websocket import _INTERNAL_RECEIVE_TYPES, WSMessageError
from .log import ws_logger
from .streams import EofStream
from .typedefs import JSONDecoder, JSONEncoder
from .typedefs import JSONBytesEncoder, JSONDecoder, JSONEncoder
from .web_exceptions import HTTPBadRequest, HTTPException
from .web_request import BaseRequest
from .web_response import StreamResponse
Expand Down Expand Up @@ -481,6 +481,20 @@ async def send_json(
) -> None:
await self.send_str(dumps(data), compress=compress)

async def send_json_bytes(
self,
data: Any,
compress: int | None = None,
*,
dumps: JSONBytesEncoder,
) -> None:
"""Send JSON data using a bytes-returning encoder as a binary frame.

Use this when your JSON encoder (like orjson) returns bytes
instead of str, avoiding the encode/decode overhead.
"""
await self.send_bytes(dumps(data), compress=compress)

async def write_eof(self) -> None: # type: ignore[override]
if self._eof_sent:
return
Expand Down
22 changes: 22 additions & 0 deletions docs/client_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,28 @@ manually.
The method is converted into :term:`coroutine`,
*compress* parameter added.

.. method:: send_json_bytes(data, compress=None, *, dumps)
:async:

Send *data* to peer as a JSON binary frame using a bytes-returning encoder.

:param data: data to send.

:param int compress: sets specific level of compression for
single message,
``None`` for not overriding per-socket setting.

:param collections.abc.Callable dumps: any :term:`callable` that accepts an object and
returns JSON as :class:`bytes`
(e.g. ``orjson.dumps``).

:raise RuntimeError: if connection is not started or closing

:raise ValueError: if data is not serializable object

:raise TypeError: if value returned by ``dumps(data)`` is not
:class:`bytes`

.. method:: send_frame(message, opcode, compress=None)
:async:

Expand Down
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ nowait
OAuth
Online
optimizations
orjson
os
outcoming
Overridable
Expand Down
33 changes: 33 additions & 0 deletions docs/web_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,27 @@ and :ref:`aiohttp-web-signals` handlers::
The method is converted into :term:`coroutine`,
*compress* parameter added.

.. method:: send_json_bytes(data, compress=None, *, dumps)
:async:

Send *data* to peer as a JSON binary frame using a bytes-returning encoder.

:param data: data to send.

:param int compress: sets specific level of compression for
single message,
``None`` for not overriding per-socket setting.

:param collections.abc.Callable dumps: any :term:`callable` that accepts an object and
returns JSON as :class:`bytes`
(e.g. ``orjson.dumps``).

:raise RuntimeError: if the connection is not started.

:raise ValueError: if data is not serializable object

:raise TypeError: if value returned by ``dumps`` param is not :class:`bytes`

.. method:: send_frame(message, opcode, compress=None)
:async:

Expand Down Expand Up @@ -1389,6 +1410,18 @@ content type and *data* encoded by ``dumps`` parameter
(:func:`json.dumps` by default).


.. function:: json_bytes_response([data], *, dumps, body=None, \
status=200, reason=None, headers=None, \
content_type='application/json')

Return :class:`Response` with predefined ``'application/json'``
content type and *data* encoded by ``dumps`` parameter
which must return :class:`bytes` directly (e.g. ``orjson.dumps``).

Use this when your JSON encoder returns :class:`bytes` instead of :class:`str`,
avoiding the :class:`str`-to-:class:`bytes` encoding overhead.


.. class:: ResponseKey(name, t)
:canonical: aiohttp.helpers.ResponseKey

Expand Down
Loading
Loading