Repository: django/asgiref
Pull request #272 (2021-06-25 21:25:30)

📝 Rename Websocket by WebSocket
This is fundamentally unnecessary, so the rejecting this PR is understandable. But the correct name is WebSocket.

andrewgodwin: I don't object to it, but if you want to land this you'll need to do it in a backwards-compatible fashion (so the old names work but raise a warning)
Kludex: I've used [PEP 562](https://www.python.org/dev/peps/pep-0562/) idea here, but as it was introduced in 3.7, I've used the [backport implementation for 3.6](https://github.com/facelessuser/pep562).

I've also added tests to check that the warning is being raised on the wanted types.

Patch:
diff --git a/README.rst b/README.rst
index 3ff7db1..fd13913 100644
--- a/README.rst
+++ b/README.rst
@@ -189,7 +189,7 @@ This means you now have two basic states:
   runs on the main thread's event loop, and all ``thread_sensitive`` synchronous
   code will run in a single shared sub-thread.
 
-Cruicially, this means that in both cases there is a thread which is a shared
+Crucially, this means that in both cases there is a thread which is a shared
 resource that all ``thread_sensitive`` code must run on, and there is a chance
 that this thread is currently blocked on its own ``AsyncToSync`` call. Thus,
 ``AsyncToSync`` needs to act as an executor for thread code while it's blocking.
diff --git a/asgiref/_pep562.py b/asgiref/_pep562.py
new file mode 100644
index 0000000..3059257
--- /dev/null
+++ b/asgiref/_pep562.py
@@ -0,0 +1,61 @@
+"""
+Backport of PEP 562.
+https://pypi.org/search/?q=pep562
+Licensed under MIT
+Copyright (c) 2018 Isaac Muse <isaacmuse@gmail.com>
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
+and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in all copies or substantial portions
+of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+IN THE SOFTWARE.
+"""
+import sys
+from typing import Any, Callable, List, Optional
+
+
+class Pep562:
+    """
+    Backport of PEP 562 <https://pypi.org/search/?q=pep562>.
+    Wraps the module in a class that exposes the mechanics to override `__dir__` and `__getattr__`.
+    The given module will be searched for overrides of `__dir__` and `__getattr__` and use them when needed.
+    """
+
+    def __init__(self, name: str) -> None:
+        """Acquire `__getattr__` and `__dir__`, but only replace module for versions less than Python 3.7."""
+
+        self._module = sys.modules[name]
+        self._get_attr = getattr(self._module, "__getattr__", None)
+        self._get_dir: Optional[Callable[..., List[str]]] = getattr(
+            self._module, "__dir__", None
+        )
+        sys.modules[name] = self  # type: ignore[assignment]
+
+    def __dir__(self) -> List[str]:
+        """Return the overridden `dir` if one was provided, else apply `dir` to the module."""
+
+        return self._get_dir() if self._get_dir else dir(self._module)
+
+    def __getattr__(self, name: str) -> Any:
+        """
+        Attempt to retrieve the attribute from the module, and if missing, use the overridden function if present.
+        """
+
+        try:
+            return getattr(self._module, name)
+        except AttributeError:
+            if self._get_attr:
+                return self._get_attr(name)
+            raise
+
+
+def pep562(module_name: str) -> None:
+    """Helper function to apply PEP 562."""
+
+    if sys.version_info < (3, 7):
+        Pep562(module_name)
diff --git a/asgiref/typing.py b/asgiref/typing.py
index cc9181b..775d11c 100644
--- a/asgiref/typing.py
+++ b/asgiref/typing.py
@@ -1,11 +1,50 @@
 import sys
-from typing import Awaitable, Callable, Dict, Iterable, Optional, Tuple, Type, Union
+import warnings
+from typing import Any, Awaitable, Callable, Dict, Iterable, List, Optional, Tuple, Type, Union
+
+from asgiref._pep562 import pep562
 
 if sys.version_info >= (3, 8):
     from typing import Literal, Protocol, TypedDict
 else:
     from typing_extensions import Literal, Protocol, TypedDict
 
+__all__ = (
+    "ASGIVersions",
+    "HTTPScope",
+    "WebSocketScope",
+    "LifespanScope",
+    "WWWScope",
+    "Scope",
+    "HTTPRequestEvent",
+    "HTTPResponseStartEvent",
+    "HTTPResponseBodyEvent",
+    "HTTPServerPushEvent",
+    "HTTPDisconnectEvent",
+    "WebSocketConnectEvent",
+    "WebSocketAcceptEvent",
+    "WebSocketReceiveEvent",
+    "WebSocketSendEvent",
+    "WebSocketResponseStartEvent",
+    "WebSocketResponseBodyEvent",
+    "WebSocketDisconnectEvent",
+    "WebSocketCloseEvent",
+    "LifespanStartupEvent",
+    "LifespanShutdownEvent",
+    "LifespanStartupCompleteEvent",
+    "LifespanStartupFailedEvent",
+    "LifespanShutdownCompleteEvent",
+    "LifespanShutdownFailedEvent",
+    "ASGIReceiveEvent",
+    "ASGISendEvent",
+    "ASGIReceiveCallable",
+    "ASGISendCallable",
+    "ASGI2Protocol",
+    "ASGI2Application",
+    "ASGI3Application",
+    "ASGIApplication",
+)
+
 
 class ASGIVersions(TypedDict):
     spec_version: str
@@ -25,10 +64,10 @@ class HTTPScope(TypedDict):
     headers: Iterable[Tuple[bytes, bytes]]
     client: Optional[Tuple[str, int]]
     server: Optional[Tuple[str, Optional[int]]]
-    extensions: Dict[str, Dict[object, object]]
+    extensions: Optional[Dict[str, Dict[object, object]]]
 
 
-class WebsocketScope(TypedDict):
+class WebSocketScope(TypedDict):
     type: Literal["websocket"]
     asgi: ASGIVersions
     http_version: str
@@ -41,7 +80,7 @@ class WebsocketScope(TypedDict):
     client: Optional[Tuple[str, int]]
     server: Optional[Tuple[str, Optional[int]]]
     subprotocols: Iterable[str]
-    extensions: Dict[str, Dict[object, object]]
+    extensions: Optional[Dict[str, Dict[object, object]]]
 
 
 class LifespanScope(TypedDict):
@@ -49,8 +88,8 @@ class LifespanScope(TypedDict):
     asgi: ASGIVersions
 
 
-WWWScope = Union[HTTPScope, WebsocketScope]
-Scope = Union[HTTPScope, WebsocketScope, LifespanScope]
+WWWScope = Union[HTTPScope, WebSocketScope]
+Scope = Union[HTTPScope, WebSocketScope, LifespanScope]
 
 
 class HTTPRequestEvent(TypedDict):
@@ -81,46 +120,46 @@ class HTTPDisconnectEvent(TypedDict):
     type: Literal["http.disconnect"]
 
 
-class WebsocketConnectEvent(TypedDict):
+class WebSocketConnectEvent(TypedDict):
     type: Literal["websocket.connect"]
 
 
-class WebsocketAcceptEvent(TypedDict):
+class WebSocketAcceptEvent(TypedDict):
     type: Literal["websocket.accept"]
     subprotocol: Optional[str]
     headers: Iterable[Tuple[bytes, bytes]]
 
 
-class WebsocketReceiveEvent(TypedDict):
+class WebSocketReceiveEvent(TypedDict):
     type: Literal["websocket.receive"]
     bytes: Optional[bytes]
     text: Optional[str]
 
 
-class WebsocketSendEvent(TypedDict):
+class WebSocketSendEvent(TypedDict):
     type: Literal["websocket.send"]
     bytes: Optional[bytes]
     text: Optional[str]
 
 
-class WebsocketResponseStartEvent(TypedDict):
+class WebSocketResponseStartEvent(TypedDict):
     type: Literal["websocket.http.response.start"]
     status: int
     headers: Iterable[Tuple[bytes, bytes]]
 
 
-class WebsocketResponseBodyEvent(TypedDict):
+class WebSocketResponseBodyEvent(TypedDict):
     type: Literal["websocket.http.response.body"]
     body: bytes
     more_body: bool
 
 
-class WebsocketDisconnectEvent(TypedDict):
+class WebSocketDisconnectEvent(TypedDict):
     type: Literal["websocket.disconnect"]
     code: int
 
 
-class WebsocketCloseEvent(TypedDict):
+class WebSocketCloseEvent(TypedDict):
     type: Literal["websocket.close"]
     code: int
     reason: Optional[str]
@@ -155,9 +194,9 @@ class LifespanShutdownFailedEvent(TypedDict):
 ASGIReceiveEvent = Union[
     HTTPRequestEvent,
     HTTPDisconnectEvent,
-    WebsocketConnectEvent,
-    WebsocketReceiveEvent,
-    WebsocketDisconnectEvent,
+    WebSocketConnectEvent,
+    WebSocketReceiveEvent,
+    WebSocketDisconnectEvent,
     LifespanStartupEvent,
     LifespanShutdownEvent,
 ]
@@ -168,11 +207,11 @@ ASGISendEvent = Union[
     HTTPResponseBodyEvent,
     HTTPServerPushEvent,
     HTTPDisconnectEvent,
-    WebsocketAcceptEvent,
-    WebsocketSendEvent,
-    WebsocketResponseStartEvent,
-    WebsocketResponseBodyEvent,
-    WebsocketCloseEvent,
+    WebSocketAcceptEvent,
+    WebSocketSendEvent,
+    WebSocketResponseStartEvent,
+    WebSocketResponseBodyEvent,
+    WebSocketCloseEvent,
     LifespanStartupCompleteEvent,
     LifespanStartupFailedEvent,
     LifespanShutdownCompleteEvent,
@@ -204,3 +243,34 @@ ASGI3Application = Callable[
     Awaitable[None],
 ]
 ASGIApplication = Union[ASGI2Application, ASGI3Application]
+
+__deprecated__ = {
+    "WebsocketConnectEvent": WebSocketConnectEvent,
+    "WebsocketAcceptEvent": WebSocketAcceptEvent,
+    "WebsocketReceiveEvent": WebSocketReceiveEvent,
+    "WebsocketSendEvent": WebSocketSendEvent,
+    "WebsocketResponseStartEvent": WebSocketResponseStartEvent,
+    "WebsocketResponseBodyEvent": WebSocketResponseBodyEvent,
+    "WebsocketDisconnectEvent": WebSocketDisconnectEvent,
+    "WebsocketCloseEvent": WebSocketCloseEvent,
+}
+
+
+def __getattr__(name: str) -> Any:
+    deprecated = __deprecated__.get(name)
+    if deprecated:
+        stacklevel = 3 if sys.version_info >= (3, 7) else 4
+        warnings.warn(
+            f"'{name}' is deprecated. Use '{deprecated.__name__}' instead.",
+            category=DeprecationWarning,
+            stacklevel=stacklevel,
+        )
+        return deprecated
+    raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
+
+
+def __dir__() -> List[str]:
+    return sorted(list(__all__) + list(__deprecated__.keys()))
+
+
+pep562(__name__)
diff --git a/tox.ini b/tox.ini
index 18979ec..b3be3e4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,7 +11,7 @@ commands =
     mypy: mypy . {posargs}
 
 [testenv:qa]
-skip_install=true
+skip_install = true
 deps =
     pre-commit
 commands =


Test patch:
diff --git a/tests/test_deprecated_types.py b/tests/test_deprecated_types.py
new file mode 100644
index 0000000..0adacff
--- /dev/null
+++ b/tests/test_deprecated_types.py
@@ -0,0 +1,20 @@
+import importlib
+
+import pytest
+
+from asgiref import typing
+
+
+@pytest.mark.parametrize("deprecated_type", typing.__deprecated__.keys())
+def test_deprecated_types(deprecated_type: str) -> None:
+    with pytest.warns(DeprecationWarning) as record:
+        getattr(importlib.import_module("asgiref.typing"), deprecated_type)
+        assert len(record) == 1
+        assert deprecated_type in str(record.list[0])
+
+
+@pytest.mark.parametrize("available_type", typing.__all__)
+def test_available_types(available_type: str) -> None:
+    with pytest.warns(None) as record:
+        getattr(importlib.import_module("asgiref.typing"), available_type)
+        assert len(record) == 0