Skip to content

Commit 3005510

Browse files
authored
Request/Response storage typing (aio-libs#11766)
1 parent 72fadb8 commit 3005510

17 files changed

+326
-38
lines changed

CHANGES/11766.feature.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added ``RequestKey`` and ``ResponseKey`` classes,
2+
which enable static type checking for request & response
3+
context storages in the same way that ``AppKey`` does for ``Application``
4+
-- by :user:`gsoldatov`.

CONTRIBUTORS.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ Gennady Andreyev
150150
Georges Dubus
151151
Greg Holt
152152
Gregory Haynes
153+
Grigoriy Soldatov
153154
Gus Goulart
154155
Gustavo Carneiro
155156
Günther Jena

aiohttp/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ def __init__(
380380

381381
def __init_subclass__(cls: type["ClientSession"]) -> None:
382382
raise TypeError(
383-
f"Inheritance class {cls.__name__} from ClientSession " "is forbidden"
383+
f"Inheritance class {cls.__name__} from ClientSession is forbidden"
384384
)
385385

386386
def __del__(self, _warnings: Any = warnings) -> None:

aiohttp/helpers.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -834,8 +834,11 @@ def set_exception(
834834

835835

836836
@functools.total_ordering
837-
class AppKey(Generic[_T]):
838-
"""Keys for static typing support in Application."""
837+
class BaseKey(Generic[_T]):
838+
"""Base for concrete context storage key classes.
839+
840+
Each storage is provided with its own sub-class for the sake of some additional type safety.
841+
"""
839842

840843
__slots__ = ("_name", "_t", "__orig_class__")
841844

@@ -861,9 +864,9 @@ def __init__(self, name: str, t: type[_T] | None = None):
861864
self._t = t
862865

863866
def __lt__(self, other: object) -> bool:
864-
if isinstance(other, AppKey):
867+
if isinstance(other, BaseKey):
865868
return self._name < other._name
866-
return True # Order AppKey above other types.
869+
return True # Order BaseKey above other types.
867870

868871
def __repr__(self) -> str:
869872
t = self._t
@@ -881,7 +884,19 @@ def __repr__(self) -> str:
881884
t_repr = f"{t.__module__}.{t.__qualname__}"
882885
else:
883886
t_repr = repr(t) # type: ignore[unreachable]
884-
return f"<AppKey({self._name}, type={t_repr})>"
887+
return f"<{self.__class__.__name__}({self._name}, type={t_repr})>"
888+
889+
890+
class AppKey(BaseKey[_T]):
891+
"""Keys for static typing support in Application."""
892+
893+
894+
class RequestKey(BaseKey[_T]):
895+
"""Keys for static typing support in Request."""
896+
897+
898+
class ResponseKey(BaseKey[_T]):
899+
"""Keys for static typing support in Response."""
885900

886901

887902
@final
@@ -893,7 +908,7 @@ def __init__(self, maps: Iterable[Mapping[str | AppKey[Any], Any]]) -> None:
893908

894909
def __init_subclass__(cls) -> None:
895910
raise TypeError(
896-
f"Inheritance class {cls.__name__} from ChainMapProxy " "is forbidden"
911+
f"Inheritance class {cls.__name__} from ChainMapProxy is forbidden"
897912
)
898913

899914
@overload # type: ignore[override]

aiohttp/web.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import Any, cast
1212

1313
from .abc import AbstractAccessLogger
14-
from .helpers import AppKey
14+
from .helpers import AppKey, RequestKey, ResponseKey
1515
from .log import access_logger
1616
from .typedefs import PathLike
1717
from .web_app import Application, CleanupError
@@ -203,11 +203,13 @@
203203
"BaseRequest",
204204
"FileField",
205205
"Request",
206+
"RequestKey",
206207
# web_response
207208
"ContentCoding",
208209
"Response",
209210
"StreamResponse",
210211
"json_response",
212+
"ResponseKey",
211213
# web_routedef
212214
"AbstractRouteDef",
213215
"RouteDef",

aiohttp/web_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def __init__(
130130

131131
def __init_subclass__(cls: type["Application"]) -> None:
132132
raise TypeError(
133-
f"Inheritance class {cls.__name__} from web.Application " "is forbidden"
133+
f"Inheritance class {cls.__name__} from web.Application is forbidden"
134134
)
135135

136136
# MutableMapping API

aiohttp/web_protocol.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ async def finish_response(
716716
self.log_exception("Missing return statement on request handler") # type: ignore[unreachable]
717717
else:
718718
self.log_exception(
719-
"Web-handler should return a response instance, " f"got {resp!r}"
719+
f"Web-handler should return a response instance, got {resp!r}"
720720
)
721721
exc = HTTPInternalServerError()
722722
resp = Response(

aiohttp/web_request.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
import sys
88
import tempfile
99
import types
10+
import warnings
1011
from collections.abc import Iterator, Mapping, MutableMapping
1112
from re import Pattern
1213
from types import MappingProxyType
13-
from typing import TYPE_CHECKING, Any, Final, Optional, cast
14+
from typing import TYPE_CHECKING, Any, Final, Optional, TypeVar, cast, overload
1415
from urllib.parse import parse_qsl
1516

1617
from multidict import CIMultiDict, CIMultiDictProxy, MultiDict, MultiDictProxy
@@ -26,6 +27,7 @@
2627
ChainMapProxy,
2728
ETag,
2829
HeadersMixin,
30+
RequestKey,
2931
frozen_dataclass_decorator,
3032
is_expected_content_type,
3133
parse_http_date,
@@ -48,6 +50,7 @@
4850
HTTPBadRequest,
4951
HTTPRequestEntityTooLarge,
5052
HTTPUnsupportedMediaType,
53+
NotAppKeyWarning,
5154
)
5255
from .web_response import StreamResponse
5356

@@ -65,6 +68,9 @@
6568
from .web_urldispatcher import UrlMappingMatchInfo
6669

6770

71+
_T = TypeVar("_T")
72+
73+
6874
@frozen_dataclass_decorator
6975
class FileField:
7076
name: str
@@ -101,7 +107,7 @@ class FileField:
101107
############################################################
102108

103109

104-
class BaseRequest(MutableMapping[str, Any], HeadersMixin):
110+
class BaseRequest(MutableMapping[str | RequestKey[Any], Any], HeadersMixin):
105111
POST_METHODS = {
106112
hdrs.METH_PATCH,
107113
hdrs.METH_POST,
@@ -112,6 +118,7 @@ class BaseRequest(MutableMapping[str, Any], HeadersMixin):
112118

113119
_post: MultiDictProxy[str | bytes | FileField] | None = None
114120
_read_bytes: bytes | None = None
121+
_seen_str_keys: set[str] = set()
115122

116123
def __init__(
117124
self,
@@ -123,7 +130,7 @@ def __init__(
123130
loop: asyncio.AbstractEventLoop,
124131
*,
125132
client_max_size: int = 1024**2,
126-
state: dict[str, Any] | None = None,
133+
state: dict[RequestKey[Any] | str, Any] | None = None,
127134
scheme: str | None = None,
128135
host: str | None = None,
129136
remote: str | None = None,
@@ -253,19 +260,40 @@ def rel_url(self) -> URL:
253260

254261
# MutableMapping API
255262

256-
def __getitem__(self, key: str) -> Any:
263+
@overload # type: ignore[override]
264+
def __getitem__(self, key: RequestKey[_T]) -> _T: ...
265+
266+
@overload
267+
def __getitem__(self, key: str) -> Any: ...
268+
269+
def __getitem__(self, key: str | RequestKey[_T]) -> Any:
257270
return self._state[key]
258271

259-
def __setitem__(self, key: str, value: Any) -> None:
272+
@overload # type: ignore[override]
273+
def __setitem__(self, key: RequestKey[_T], value: _T) -> None: ...
274+
275+
@overload
276+
def __setitem__(self, key: str, value: Any) -> None: ...
277+
278+
def __setitem__(self, key: str | RequestKey[_T], value: Any) -> None:
279+
if not isinstance(key, RequestKey) and key not in BaseRequest._seen_str_keys:
280+
BaseRequest._seen_str_keys.add(key)
281+
warnings.warn(
282+
"It is recommended to use web.RequestKey instances for keys.\n"
283+
+ "https://docs.aiohttp.org/en/stable/web_advanced.html"
284+
+ "#request-s-storage",
285+
category=NotAppKeyWarning,
286+
stacklevel=2,
287+
)
260288
self._state[key] = value
261289

262-
def __delitem__(self, key: str) -> None:
290+
def __delitem__(self, key: str | RequestKey[_T]) -> None:
263291
del self._state[key]
264292

265293
def __len__(self) -> int:
266294
return len(self._state)
267295

268-
def __iter__(self) -> Iterator[str]:
296+
def __iter__(self) -> Iterator[str | RequestKey[Any]]:
269297
return iter(self._state)
270298

271299
########

aiohttp/web_response.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from collections.abc import Iterator, MutableMapping
99
from concurrent.futures import Executor
1010
from http import HTTPStatus
11-
from typing import TYPE_CHECKING, Any, Optional, Union, cast
11+
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, cast, overload
1212

1313
from multidict import CIMultiDict, istr
1414

@@ -21,6 +21,7 @@
2121
CookieMixin,
2222
ETag,
2323
HeadersMixin,
24+
ResponseKey,
2425
must_be_empty_body,
2526
parse_http_date,
2627
populate_with_cookies,
@@ -32,6 +33,7 @@
3233
from .http import SERVER_SOFTWARE, HttpVersion10, HttpVersion11
3334
from .payload import Payload
3435
from .typedefs import JSONEncoder, LooseHeaders
36+
from .web_exceptions import NotAppKeyWarning
3537

3638
REASON_PHRASES = {http_status.value: http_status.phrase for http_status in HTTPStatus}
3739
LARGE_BODY_SIZE = 1024**2
@@ -43,6 +45,9 @@
4345
from .web_request import BaseRequest
4446

4547

48+
_T = TypeVar("_T")
49+
50+
4651
# TODO(py311): Convert to StrEnum for wider use
4752
class ContentCoding(enum.Enum):
4853
# The content codings that we have support for.
@@ -61,7 +66,9 @@ class ContentCoding(enum.Enum):
6166
############################################################
6267

6368

64-
class StreamResponse(MutableMapping[str, Any], HeadersMixin, CookieMixin):
69+
class StreamResponse(
70+
MutableMapping[str | ResponseKey[Any], Any], HeadersMixin, CookieMixin
71+
):
6572

6673
_body: None | bytes | bytearray | Payload
6774
_length_check = True
@@ -77,6 +84,7 @@ class StreamResponse(MutableMapping[str, Any], HeadersMixin, CookieMixin):
7784
_must_be_empty_body: bool | None = None
7885
_body_length = 0
7986
_send_headers_immediately = True
87+
_seen_str_keys: set[str] = set()
8088

8189
def __init__(
8290
self,
@@ -93,7 +101,7 @@ def __init__(
93101
the headers when creating a new response object. It is not intended
94102
to be used by external code.
95103
"""
96-
self._state: dict[str, Any] = {}
104+
self._state: dict[str | ResponseKey[Any], Any] = {}
97105

98106
if _real_headers is not None:
99107
self._headers = _real_headers
@@ -483,19 +491,43 @@ def __repr__(self) -> str:
483491
info = "not prepared"
484492
return f"<{self.__class__.__name__} {self.reason} {info}>"
485493

486-
def __getitem__(self, key: str) -> Any:
494+
@overload # type: ignore[override]
495+
def __getitem__(self, key: ResponseKey[_T]) -> _T: ...
496+
497+
@overload
498+
def __getitem__(self, key: str) -> Any: ...
499+
500+
def __getitem__(self, key: str | ResponseKey[_T]) -> Any:
487501
return self._state[key]
488502

489-
def __setitem__(self, key: str, value: Any) -> None:
503+
@overload # type: ignore[override]
504+
def __setitem__(self, key: ResponseKey[_T], value: _T) -> None: ...
505+
506+
@overload
507+
def __setitem__(self, key: str, value: Any) -> None: ...
508+
509+
def __setitem__(self, key: str | ResponseKey[_T], value: Any) -> None:
510+
if (
511+
not isinstance(key, ResponseKey)
512+
and key not in StreamResponse._seen_str_keys
513+
):
514+
StreamResponse._seen_str_keys.add(key)
515+
warnings.warn(
516+
"It is recommended to use web.ResponseKey instances for keys.\n"
517+
+ "https://docs.aiohttp.org/en/stable/web_advanced.html"
518+
+ "#response-s-storage",
519+
category=NotAppKeyWarning,
520+
stacklevel=2,
521+
)
490522
self._state[key] = value
491523

492-
def __delitem__(self, key: str) -> None:
524+
def __delitem__(self, key: str | ResponseKey[_T]) -> None:
493525
del self._state[key]
494526

495527
def __len__(self) -> int:
496528
return len(self._state)
497529

498-
def __iter__(self) -> Iterator[str]:
530+
def __iter__(self) -> Iterator[str | ResponseKey[Any]]:
499531
return iter(self._state)
500532

501533
def __hash__(self) -> int:

aiohttp/web_runner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ def __init__(
373373
) -> None:
374374
if not isinstance(app, Application):
375375
raise TypeError(
376-
"The first argument should be web.Application " f"instance, got {app!r}"
376+
f"The first argument should be web.Application instance, got {app!r}"
377377
)
378378
kwargs["access_log_class"] = access_log_class
379379

0 commit comments

Comments
 (0)