Repository: falconry/falcon

Issue:
Support http.HTTPStatus
It would be great to support [`http.HTTPStatus`](https://docs.python.org/3/library/http.html#http.HTTPStatus) from the standard library (3.5+).

Could possibly look like:

```python
from http import HTTPStatus

class MyResource:
    def on_get(self, req, res):
        res.status = HTTPStatus.NOT_FOUND  # instead of falcon.HTTP_404
```
  

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1735?src=pr&el=h1) Report
> Merging [#1735](https://codecov.io/gh/falconry/falcon/pull/1735?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/0fc91cbf028779cf0f7a8761dded149683429adb&el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1735/graphs/tree.svg?width=650&height=150&src=pr&token=zOtkfNQ3h6)](https://codecov.io/gh/falconry/falcon/pull/1735?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master     #1735   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           55        55           
  Lines         4967      4975    +8     
  Branches       804       807    +3     
=========================================
+ Hits          4967      4975    +8     
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1735?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/response.py](https://codecov.io/gh/falconry/falcon/pull/1735/diff?src=pr&el=tree#diff-ZmFsY29uL3Jlc3BvbnNlLnB5) | `100.00% <ø> (ø)` | |
| [falcon/app.py](https://codecov.io/gh/falconry/falcon/pull/1735/diff?src=pr&el=tree#diff-ZmFsY29uL2FwcC5weQ==) | `100.00% <100.00%> (ø)` | |
| [falcon/util/misc.py](https://codecov.io/gh/falconry/falcon/pull/1735/diff?src=pr&el=tree#diff-ZmFsY29uL3V0aWwvbWlzYy5weQ==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1735?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1735?src=pr&el=footer). Last update [0fc91cb...00c6c54](https://codecov.io/gh/falconry/falcon/pull/1735?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/docs/_newsfragments/1135.newandimproved.rst b/docs/_newsfragments/1135.newandimproved.rst
new file mode 100644
index 0000000..b4f51a3
--- /dev/null
+++ b/docs/_newsfragments/1135.newandimproved.rst
@@ -0,0 +1,3 @@
+The :attr:`falcon.Response.status` attribute can now be also set to an
+``http.HTTPStatus`` instance, an integer status code, as well as anything
+supported by the :func:`falcon.code_to_http_status` utility method.
diff --git a/falcon/app.py b/falcon/app.py
index 72ed68d..5b85a67 100644
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -29,6 +29,7 @@ import falcon.responders
 from falcon.response import Response, ResponseOptions
 import falcon.status_codes as status
 from falcon.util import misc
+from falcon.util.misc import code_to_http_status
 
 
 # PERF(vytas): On Python 3.5+ (including cythonized modules),
@@ -344,7 +345,7 @@ class App:
 
             req_succeeded = False
 
-        resp_status = resp.status
+        resp_status = code_to_http_status(resp.status)
         default_media_type = self.resp_options.default_media_type
 
         if req.method == 'HEAD' or resp_status in _BODILESS_STATUS_CODES:
diff --git a/falcon/response.py b/falcon/response.py
index 183a396..dc3850b 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -56,13 +56,9 @@ class Response:
         options (dict): Set of global options passed from the App handler.
 
     Attributes:
-        status (str): HTTP status line (e.g., ``'200 OK'``). Falcon requires
-            the full status line, not just the code (e.g., 200). This design
-            makes the framework more efficient because it does not have to
-            do any kind of conversion or lookup when composing the WSGI
-            response.
-
-            If not set explicitly, the status defaults to ``'200 OK'``.
+        status: HTTP status code or line (e.g., ``'200 OK'``). This may be set
+            to a member of :class:`http.HTTPStatus`, an HTTP status line string
+            or byte string (e.g., ``'200 OK'``), or an ``int``.
 
             Note:
                 The Falcon framework itself provides a number of constants for
diff --git a/falcon/util/misc.py b/falcon/util/misc.py
index 03bf0df..e7ba957 100644
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -56,6 +56,8 @@ __all__ = (
     'secure_filename',
 )
 
+_DEFAULT_HTTP_REASON = 'Unknown'
+
 _UNSAFE_CHARS = re.compile(r'[^a-zA-Z0-9.-]')
 
 # PERF(kgriffs): Avoid superfluous namespace lookups
@@ -342,7 +344,7 @@ def get_argnames(func):
 
 
 @deprecated('Please use falcon.code_to_http_status() instead.')
-def get_http_status(status_code, default_reason='Unknown'):
+def get_http_status(status_code, default_reason=_DEFAULT_HTTP_REASON):
     """Gets both the http status code and description from just a code.
 
     Warning:
@@ -430,7 +432,7 @@ def http_status_to_code(status):
     An LRU is used to minimize lookup time.
 
     Args:
-        status: The status code or enum to normalize
+        status: The status code or enum to normalize.
 
     Returns:
         int: Integer code for the HTTP status (e.g., 200)
@@ -458,32 +460,50 @@ def http_status_to_code(status):
 
 
 @_lru_cache_safe(maxsize=64)
-def code_to_http_status(code):
-    """Convert an HTTP status code integer to a status line string.
+def code_to_http_status(status):
+    """Normalize an HTTP status to an HTTP status line string.
+
+    This function takes a member of :class:`http.HTTPStatus`, an ``int`` status
+    code, an HTTP status line string or byte string (e.g., ``'200 OK'``) and
+    returns the corresponding HTTP status line string.
 
     An LRU is used to minimize lookup time.
 
+    Note:
+        Unlike the deprecated :func:`get_http_status`, this function will not
+        attempt to coerce a string status to an integer code, assuming the
+        string already denotes an HTTP status line.
+
     Args:
-        code (int): The integer status code to convert to a status line.
+        status: The status code or enum to normalize.
 
     Returns:
         str: HTTP status line corresponding to the given code. A newline
             is not included at the end of the string.
     """
 
+    if isinstance(status, http.HTTPStatus):
+        return '{} {}'.format(status.value, status.phrase)
+
+    if isinstance(status, str):
+        return status
+
+    if isinstance(status, bytes):
+        return status.decode()
+
     try:
-        code = int(code)
-        if code < 100:
-            raise ValueError()
+        code = int(status)
+        if not 100 <= code <= 999:
+            raise ValueError('{} is not a valid status code'.format(code))
     except (ValueError, TypeError):
-        raise ValueError('"{}" is not a valid status code'.format(code))
+        raise ValueError('{!r} is not a valid status code'.format(code))
 
     try:
         # NOTE(kgriffs): We do this instead of using http.HTTPStatus since
         #   the Falcon module defines a larger number of codes.
         return getattr(status_codes, 'HTTP_' + str(code))
     except AttributeError:
-        return str(code)
+        return '{} {}'.format(code, _DEFAULT_HTTP_REASON)
 
 
 def deprecated_args(*, allowed_positional, is_method=True):


Test patch:
diff --git a/tests/test_httpstatus.py b/tests/test_httpstatus.py
index c233ab3..cb400c5 100644
--- a/tests/test_httpstatus.py
+++ b/tests/test_httpstatus.py
@@ -1,5 +1,7 @@
 # -*- coding: utf-8
 
+import http
+
 import pytest
 
 import falcon
@@ -230,3 +232,42 @@ class TestNoBodyWithStatus:
         res = body_client.simulate_put('/status')
         assert res.status == falcon.HTTP_719
         assert res.content == b''
+
+
+@pytest.fixture()
+def custom_status_client(asgi):
+    def client(status):
+        class Resource:
+            def on_get(self, req, resp):
+                resp.content_type = falcon.MEDIA_TEXT
+                resp.data = b'Hello, World!'
+                resp.status = status
+
+        app = create_app(asgi=asgi)
+        app.add_route('/status', Resource())
+        return testing.TestClient(app)
+
+    return client
+
+
+@pytest.mark.parametrize('status,expected_code', [
+    (http.HTTPStatus(200), 200),
+    (http.HTTPStatus(202), 202),
+    (http.HTTPStatus(403), 403),
+    (http.HTTPStatus(500), 500),
+    (http.HTTPStatus.OK, 200),
+    (http.HTTPStatus.USE_PROXY, 305),
+    (http.HTTPStatus.NOT_FOUND, 404),
+    (http.HTTPStatus.NOT_IMPLEMENTED, 501),
+    (200, 200),
+    (307, 307),
+    (500, 500),
+    (702, 702),
+    (b'200 OK', 200),
+    (b'702 Emacs', 702),
+])
+def test_non_string_status(custom_status_client, status, expected_code):
+    client = custom_status_client(status)
+    resp = client.simulate_get('/status')
+    assert resp.text == 'Hello, World!'
+    assert resp.status_code == expected_code
diff --git a/tests/test_utils.py b/tests/test_utils.py
index cf8558b..6cd5866 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -415,8 +415,19 @@ class TestFalconUtils:
             (703, falcon.HTTP_703),
             (404, falcon.HTTP_404),
             (404.9, falcon.HTTP_404),
-            ('404', falcon.HTTP_404),
-            (123, '123'),
+            (falcon.HTTP_200, falcon.HTTP_200),
+            (falcon.HTTP_307, falcon.HTTP_307),
+            (falcon.HTTP_404, falcon.HTTP_404),
+            (123, '123 Unknown'),
+            ('123 Wow Such Status', '123 Wow Such Status'),
+            (b'123 Wow Such Status', '123 Wow Such Status'),
+            (b'200 OK', falcon.HTTP_OK),
+            (http.HTTPStatus(200), falcon.HTTP_200),
+            (http.HTTPStatus(307), falcon.HTTP_307),
+            (http.HTTPStatus(401), falcon.HTTP_401),
+            (http.HTTPStatus(410), falcon.HTTP_410),
+            (http.HTTPStatus(429), falcon.HTTP_429),
+            (http.HTTPStatus(500), falcon.HTTP_500),
         ]
     )
     def test_code_to_http_status(self, v_in, v_out):
@@ -424,9 +435,9 @@ class TestFalconUtils:
 
     @pytest.mark.parametrize(
         'v',
-        ['not_a_number', 0, '0', 99, '99', '404.3', -404.3, '-404', '-404.3']
+        [0, 13, 99, 1000, 1337.01, -99, -404.3, -404, -404.3]
     )
-    def test_code_to_http_status_neg(self, v):
+    def test_code_to_http_status_value_error(self, v):
         with pytest.raises(ValueError):
             falcon.code_to_http_status(v)