Repository: encode/starlette

Issue:
BUG: Incorrect WebSocket status code 1005 description, missing status code 1006
### Discussed in https://github.com/encode/starlette/discussions/1577

<div type='discussions-op-text'>

<sup>Originally posted by **vbsd** April  7, 2022</sup>
It seems that there is a mistake in starlette's WebSocket [status code list](https://github.com/encode/starlette/blob/0.19.0/starlette/status.py#L83).

Status code 1005 has the name `1005_ABNORMAL_CLOSURE` and 1006 is missing altogether. However, [Mozilla MDN docs](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code) indicate that 1005 actually means "no status received". It is status code 1006 that means "abnormal closure".

It looks like what should have been
```
WS_1005_NO_STATUS_RECEIVED = 1005
WS_1006_ABNORMAL_CLOSURE = 1006
```
got mashed together and became just one line:
```
WS_1006_NO_STATUS_RECEIVED = 1005
```

Making starlette backwards-incompatible over this wouldn't make much sense, but it seems reasonable to at least add the two correct lines and document `WS_1005_ABNORMAL_CLOSURE` as deprecated/wrong.</div>

Discussion:
aminalaee: @Kludex Is this worth the effort as Python 3.6 is already EOL and there's plan to remove support in Starlette too?
I mean as you said we can hold it after Python 3.6 is removed.
Kludex: > I mean as you said we can hold it after Python 3.6 is removed.

I didn't say that, at least I didn't mean it. I mean that we should fix it before stop supporting Python 3.6, so at least the last version has it right.
adriangb: So the idea here is to cut 1 release with those code then immediately remove it right?

So:
1. Merge this PR. Cut a patch release w/ Python 3.6 support.
2. New PR that deletes the back port code.
3. Cut a minor release dropping Python 3.6 support.

Am I getting this right?
Kludex: Yes.
Kludex: The backport removal will be on the PR that drops Python 3.6.

Patch:
diff --git a/starlette/_pep562.py b/starlette/_pep562.py
new file mode 100644
index 0000000..a4757ea
--- /dev/null
+++ b/starlette/_pep562.py
@@ -0,0 +1,62 @@
+# flake8: noqa
+"""
+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:  # pragma: no cover
+        """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]:  # pragma: no cover
+        """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:  # pragma: no cover
+        """
+        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:  # pragma: no cover
+    """Helper function to apply PEP 562."""
+
+    if sys.version_info < (3, 7):
+        Pep562(module_name)
diff --git a/starlette/status.py b/starlette/status.py
index b122ae8..cc52d89 100644
--- a/starlette/status.py
+++ b/starlette/status.py
@@ -5,6 +5,93 @@ https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
 
 And RFC 2324 - https://tools.ietf.org/html/rfc2324
 """
+import sys
+import warnings
+from typing import List
+
+from starlette._pep562 import pep562
+
+__all__ = (
+    "HTTP_100_CONTINUE",
+    "HTTP_101_SWITCHING_PROTOCOLS",
+    "HTTP_102_PROCESSING",
+    "HTTP_103_EARLY_HINTS",
+    "HTTP_200_OK",
+    "HTTP_201_CREATED",
+    "HTTP_202_ACCEPTED",
+    "HTTP_203_NON_AUTHORITATIVE_INFORMATION",
+    "HTTP_204_NO_CONTENT",
+    "HTTP_205_RESET_CONTENT",
+    "HTTP_206_PARTIAL_CONTENT",
+    "HTTP_207_MULTI_STATUS",
+    "HTTP_208_ALREADY_REPORTED",
+    "HTTP_226_IM_USED",
+    "HTTP_300_MULTIPLE_CHOICES",
+    "HTTP_301_MOVED_PERMANENTLY",
+    "HTTP_302_FOUND",
+    "HTTP_303_SEE_OTHER",
+    "HTTP_304_NOT_MODIFIED",
+    "HTTP_305_USE_PROXY",
+    "HTTP_306_RESERVED",
+    "HTTP_307_TEMPORARY_REDIRECT",
+    "HTTP_308_PERMANENT_REDIRECT",
+    "HTTP_400_BAD_REQUEST",
+    "HTTP_401_UNAUTHORIZED",
+    "HTTP_402_PAYMENT_REQUIRED",
+    "HTTP_403_FORBIDDEN",
+    "HTTP_404_NOT_FOUND",
+    "HTTP_405_METHOD_NOT_ALLOWED",
+    "HTTP_406_NOT_ACCEPTABLE",
+    "HTTP_407_PROXY_AUTHENTICATION_REQUIRED",
+    "HTTP_408_REQUEST_TIMEOUT",
+    "HTTP_409_CONFLICT",
+    "HTTP_410_GONE",
+    "HTTP_411_LENGTH_REQUIRED",
+    "HTTP_412_PRECONDITION_FAILED",
+    "HTTP_413_REQUEST_ENTITY_TOO_LARGE",
+    "HTTP_414_REQUEST_URI_TOO_LONG",
+    "HTTP_415_UNSUPPORTED_MEDIA_TYPE",
+    "HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE",
+    "HTTP_417_EXPECTATION_FAILED",
+    "HTTP_418_IM_A_TEAPOT",
+    "HTTP_421_MISDIRECTED_REQUEST",
+    "HTTP_422_UNPROCESSABLE_ENTITY",
+    "HTTP_423_LOCKED",
+    "HTTP_424_FAILED_DEPENDENCY",
+    "HTTP_425_TOO_EARLY",
+    "HTTP_426_UPGRADE_REQUIRED",
+    "HTTP_428_PRECONDITION_REQUIRED",
+    "HTTP_429_TOO_MANY_REQUESTS",
+    "HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE",
+    "HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS",
+    "HTTP_500_INTERNAL_SERVER_ERROR",
+    "HTTP_501_NOT_IMPLEMENTED",
+    "HTTP_502_BAD_GATEWAY",
+    "HTTP_503_SERVICE_UNAVAILABLE",
+    "HTTP_504_GATEWAY_TIMEOUT",
+    "HTTP_505_HTTP_VERSION_NOT_SUPPORTED",
+    "HTTP_506_VARIANT_ALSO_NEGOTIATES",
+    "HTTP_507_INSUFFICIENT_STORAGE",
+    "HTTP_508_LOOP_DETECTED",
+    "HTTP_510_NOT_EXTENDED",
+    "HTTP_511_NETWORK_AUTHENTICATION_REQUIRED",
+    "WS_1000_NORMAL_CLOSURE",
+    "WS_1001_GOING_AWAY",
+    "WS_1002_PROTOCOL_ERROR",
+    "WS_1003_UNSUPPORTED_DATA",
+    "WS_1005_NO_STATUS_RCVD",
+    "WS_1006_ABNORMAL_CLOSURE",
+    "WS_1007_INVALID_FRAME_PAYLOAD_DATA",
+    "WS_1008_POLICY_VIOLATION",
+    "WS_1009_MESSAGE_TOO_BIG",
+    "WS_1010_MANDATORY_EXT",
+    "WS_1011_INTERNAL_ERROR",
+    "WS_1012_SERVICE_RESTART",
+    "WS_1013_TRY_AGAIN_LATER",
+    "WS_1014_BAD_GATEWAY",
+    "WS_1015_TLS_HANDSHAKE",
+)
+
 HTTP_100_CONTINUE = 100
 HTTP_101_SWITCHING_PROTOCOLS = 101
 HTTP_102_PROCESSING = 102
@@ -79,8 +166,8 @@ WS_1000_NORMAL_CLOSURE = 1000
 WS_1001_GOING_AWAY = 1001
 WS_1002_PROTOCOL_ERROR = 1002
 WS_1003_UNSUPPORTED_DATA = 1003
-WS_1004_NO_STATUS_RCVD = 1004
-WS_1005_ABNORMAL_CLOSURE = 1005
+WS_1005_NO_STATUS_RCVD = 1005
+WS_1006_ABNORMAL_CLOSURE = 1006
 WS_1007_INVALID_FRAME_PAYLOAD_DATA = 1007
 WS_1008_POLICY_VIOLATION = 1008
 WS_1009_MESSAGE_TOO_BIG = 1009
@@ -90,3 +177,30 @@ WS_1012_SERVICE_RESTART = 1012
 WS_1013_TRY_AGAIN_LATER = 1013
 WS_1014_BAD_GATEWAY = 1014
 WS_1015_TLS_HANDSHAKE = 1015
+
+
+__deprecated__ = {"WS_1004_NO_STATUS_RCVD": 1004, "WS_1005_ABNORMAL_CLOSURE": 1005}
+
+
+def __getattr__(name: str) -> int:
+    deprecation_changes = {
+        "WS_1004_NO_STATUS_RCVD": "WS_1005_NO_STATUS_RCVD",
+        "WS_1005_ABNORMAL_CLOSURE": "WS_1006_ABNORMAL_CLOSURE",
+    }
+    deprecated = __deprecated__.get(name)
+    if deprecated:
+        stacklevel = 3 if sys.version_info >= (3, 7) else 4
+        warnings.warn(
+            f"'{name}' is deprecated. Use '{deprecation_changes[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()))  # pragma: no cover
+
+
+pep562(__name__)


Test patch:
diff --git a/tests/test_status.py b/tests/test_status.py
new file mode 100644
index 0000000..04719e8
--- /dev/null
+++ b/tests/test_status.py
@@ -0,0 +1,25 @@
+import importlib
+
+import pytest
+
+
+@pytest.mark.parametrize(
+    "constant,msg",
+    (
+        (
+            "WS_1004_NO_STATUS_RCVD",
+            "'WS_1004_NO_STATUS_RCVD' is deprecated. "
+            "Use 'WS_1005_NO_STATUS_RCVD' instead.",
+        ),
+        (
+            "WS_1005_ABNORMAL_CLOSURE",
+            "'WS_1005_ABNORMAL_CLOSURE' is deprecated. "
+            "Use 'WS_1006_ABNORMAL_CLOSURE' instead.",
+        ),
+    ),
+)
+def test_deprecated_types(constant: str, msg: str) -> None:
+    with pytest.warns(DeprecationWarning) as record:
+        getattr(importlib.import_module("starlette.status"), constant)
+        assert len(record) == 1
+        assert msg in str(record.list[0])