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:
Not a bad idea. Since 1.4 is on the home stretch, let's look at this for 2.0. For the sake of backwards-compatibility, we will probably want to allow both the enum and the raw string.
As discussed on Gitter, I can take a stab at this.

Basic design:
* Python 3.5+ ``http.HTTPStatus`` or its subclass in Falcon will be THE way to set status
* Python 3.5+ ``http.HTTPStatus`` will be backported to be used in older Python versions
* Falcon 1.x status constants ``falcon.HTTP_*`` will be aliased to the new enum values to maintain backward compatibility for the most common usecases

Note that, strictly speaking, it will still be a breaking change in type compatibility, so setting status to raw strings not obtained from ``falcon.HTTP_*`` constants will yield a type error.

Open question: support for ``HTCPCP/1.0`` status code ``HTCPCP 418 I'm a teapot``. It is present in the current Falcon constants, but absent in ``http.HTTPStatus``. Time permitting a shim could be implemented around ``http.HTTPStatus``, otherwise the work might be deferred until the 1st of April.
  
@kgriffs on Gitter, we agreed that a lookup table to speed things up is a reasonable approach to proceed.

You also indirectly implied it could be a status property:

> Kurt Griffiths
> Agreed, it should be good enough. One area where Falcon is opinionated is encouraging apps to follow the RFCs. Inventing app-specific codes sort of violates the uniform interface.
> In a pinch, I suppose someone could subclass Request (probably *Response* // Vytas) and override the status prop

Another alternative I had in my mind was having a lookup table on the API side.
The drawback is that should one desire to use some completely nonstandard status line (yes, we are going to maintain 7XX, teapots and coffee machines), it becomes even harder and dirtier to hook that in. Unless we add an API for that.

Thoughts?

In ``test.py`` (see pasted below), a lookup table on the API side (hence no use of properties) seems to outdo even the current approach (at least on ``py27``) due to the absence of ``if six.PY2`` clause.

```
$ python2 test.py bench
1.17599201202
1.28570485115
1.02982211113

$ python2 test.py bench
1.18289780617
1.27076292038
1.05143809319

$ python2 test.py bench
1.18374109268
1.28530693054
1.10190296173
```

```
$ python3 test.py bench
1.199260131000301
1.3444200409999212
1.2164098600001125

$ python3 test.py bench
1.2904272340001626
1.3780863749998389
1.2642014110001583

$ python3 test.py bench
1.3386266430002252
1.4676438950000374
1.3318281369997749
```

``test.py`` reads:
```python
from wsgiref.simple_server import make_server
from wsgiref.validate import validator
import enum  # needs enum34 on Python < 3.5
import sys
import timeit

import six

try:
    from http import HTTPStatus
except ImportError:
    HTTPStatus = None


# note: Python library code below does not pass PEP8 checks ;)
class BackportedHTTPStatus(enum.IntEnum):
    """HTTP status codes and reason phrases

    Status codes from the following RFCs are all observed:

        * RFC 7231: Hypertext Transfer Protocol (HTTP/1.1), obsoletes 2616
        * RFC 6585: Additional HTTP Status Codes
        * RFC 3229: Delta encoding in HTTP
        * RFC 4918: HTTP Extensions for WebDAV, obsoletes 2518
        * RFC 5842: Binding Extensions to WebDAV
        * RFC 7238: Permanent Redirect
        * RFC 2295: Transparent Content Negotiation in HTTP
        * RFC 2774: An HTTP Extension Framework
    """
    def __new__(cls, value, phrase, description=''):
        obj = int.__new__(cls, value)
        obj._value_ = value

        obj.phrase = phrase
        obj.description = description
        return obj

    # informational
    CONTINUE = 100, 'Continue', 'Request received, please continue'
    SWITCHING_PROTOCOLS = (101, 'Switching Protocols',
            'Switching to new protocol; obey Upgrade header')
    PROCESSING = 102, 'Processing'

    # success
    OK = 200, 'OK', 'Request fulfilled, document follows'
    CREATED = 201, 'Created', 'Document created, URL follows'
    ACCEPTED = (202, 'Accepted',
        'Request accepted, processing continues off-line')
    NON_AUTHORITATIVE_INFORMATION = (203,
        'Non-Authoritative Information', 'Request fulfilled from cache')
    NO_CONTENT = 204, 'No Content', 'Request fulfilled, nothing follows'
    RESET_CONTENT = 205, 'Reset Content', 'Clear input form for further input'
    PARTIAL_CONTENT = 206, 'Partial Content', 'Partial content follows'
    MULTI_STATUS = 207, 'Multi-Status'
    ALREADY_REPORTED = 208, 'Already Reported'
    IM_USED = 226, 'IM Used'

    # redirection
    MULTIPLE_CHOICES = (300, 'Multiple Choices',
        'Object has several resources -- see URI list')
    MOVED_PERMANENTLY = (301, 'Moved Permanently',
        'Object moved permanently -- see URI list')
    FOUND = 302, 'Found', 'Object moved temporarily -- see URI list'
    SEE_OTHER = 303, 'See Other', 'Object moved -- see Method and URL list'
    NOT_MODIFIED = (304, 'Not Modified',
        'Document has not changed since given time')
    USE_PROXY = (305, 'Use Proxy',
        'You must use proxy specified in Location to access this resource')
    TEMPORARY_REDIRECT = (307, 'Temporary Redirect',
        'Object moved temporarily -- see URI list')
    PERMANENT_REDIRECT = (308, 'Permanent Redirect',
        'Object moved temporarily -- see URI list')

    # client error
    BAD_REQUEST = (400, 'Bad Request',
        'Bad request syntax or unsupported method')
    UNAUTHORIZED = (401, 'Unauthorized',
        'No permission -- see authorization schemes')
    PAYMENT_REQUIRED = (402, 'Payment Required',
        'No payment -- see charging schemes')
    FORBIDDEN = (403, 'Forbidden',
        'Request forbidden -- authorization will not help')
    NOT_FOUND = (404, 'Not Found',
        'Nothing matches the given URI')
    METHOD_NOT_ALLOWED = (405, 'Method Not Allowed',
        'Specified method is invalid for this resource')
    NOT_ACCEPTABLE = (406, 'Not Acceptable',
        'URI not available in preferred format')
    PROXY_AUTHENTICATION_REQUIRED = (407,
        'Proxy Authentication Required',
        'You must authenticate with this proxy before proceeding')
    REQUEST_TIMEOUT = (408, 'Request Timeout',
        'Request timed out; try again later')
    CONFLICT = 409, 'Conflict', 'Request conflict'
    GONE = (410, 'Gone',
        'URI no longer exists and has been permanently removed')
    LENGTH_REQUIRED = (411, 'Length Required',
        'Client must specify Content-Length')
    PRECONDITION_FAILED = (412, 'Precondition Failed',
        'Precondition in headers is false')
    REQUEST_ENTITY_TOO_LARGE = (413, 'Request Entity Too Large',
        'Entity is too large')
    REQUEST_URI_TOO_LONG = (414, 'Request-URI Too Long',
        'URI is too long')
    UNSUPPORTED_MEDIA_TYPE = (415, 'Unsupported Media Type',
        'Entity body in unsupported format')
    REQUESTED_RANGE_NOT_SATISFIABLE = (416,
        'Requested Range Not Satisfiable',
        'Cannot satisfy request range')
    EXPECTATION_FAILED = (417, 'Expectation Failed',
        'Expect condition could not be satisfied')
    UNPROCESSABLE_ENTITY = 422, 'Unprocessable Entity'
    LOCKED = 423, 'Locked'
    FAILED_DEPENDENCY = 424, 'Failed Dependency'
    UPGRADE_REQUIRED = 426, 'Upgrade Required'
    PRECONDITION_REQUIRED = (428, 'Precondition Required',
        'The origin server requires the request to be conditional')
    TOO_MANY_REQUESTS = (429, 'Too Many Requests',
        'The user has sent too many requests in '
        'a given amount of time ("rate limiting")')
    REQUEST_HEADER_FIELDS_TOO_LARGE = (431,
        'Request Header Fields Too Large',
        'The server is unwilling to process the request because its header '
        'fields are too large')

    # server errors
    INTERNAL_SERVER_ERROR = (500, 'Internal Server Error',
        'Server got itself in trouble')
    NOT_IMPLEMENTED = (501, 'Not Implemented',
        'Server does not support this operation')
    BAD_GATEWAY = (502, 'Bad Gateway',
        'Invalid responses from another server/proxy')
    SERVICE_UNAVAILABLE = (503, 'Service Unavailable',
        'The server cannot process the request due to a high load')
    GATEWAY_TIMEOUT = (504, 'Gateway Timeout',
        'The gateway server did not receive a timely response')
    HTTP_VERSION_NOT_SUPPORTED = (505, 'HTTP Version Not Supported',
        'Cannot fulfill request')
    VARIANT_ALSO_NEGOTIATES = 506, 'Variant Also Negotiates'
    INSUFFICIENT_STORAGE = 507, 'Insufficient Storage'
    LOOP_DETECTED = 508, 'Loop Detected'
    NOT_EXTENDED = 510, 'Not Extended'
    NETWORK_AUTHENTICATION_REQUIRED = (511,
        'Network Authentication Required',
        'The client needs to authenticate to gain network access')


HTTPStatus = HTTPStatus or BackportedHTTPStatus

HTTP_TOO_MANY_REQUESTS_0 = '429 Too Many Requests'
HTTP_TOO_MANY_REQUESTS_1 = HTTPStatus(429)


class BareResponse0(object):

    def __init__(self):
        self.status = None


class BareResponse1(object):

    _LOOKUP_TABLE = {status: "{} {}".format(status, status.phrase)
                     for status in HTTPStatus}

    def __init__(self):
        self._raw_status = None

    @property
    def status(self):
        return self._raw_status

    @status.setter
    def status(self, value):
        self._raw_status = self._LOOKUP_TABLE[value]


class BareMetalAPI0(object):

    def __init__(self):
        pass

    def wsgi(self, environ, start_response):
        response = BareResponse0()
        response.status = HTTP_TOO_MANY_REQUESTS_0

        status = str(response.status) if six.PY2 else response.status
        headers = [('Content-type', 'text/plain')]
        start_response(status, headers)
        return ['Too fast!']


class BareMetalAPI1(object):

    def __init__(self):
        pass

    def wsgi(self, environ, start_response):
        response = BareResponse1()
        response.status = HTTP_TOO_MANY_REQUESTS_1

        status = response._raw_status
        headers = [('Content-type', 'text/plain')]
        start_response(status, headers)
        return ['Too fast!']


class BareMetalAPI2(object):

    def __init__(self):
        self._LOOKUP_TABLE = {status: "{} {}".format(status, status.phrase)
                              for status in HTTPStatus}

    def wsgi(self, environ, start_response):
        response = BareResponse0()
        response.status = HTTP_TOO_MANY_REQUESTS_1

        status = self._LOOKUP_TABLE[response.status]
        headers = [('Content-type', 'text/plain')]
        start_response(status, headers)
        return ['Too fast!']


api0 = BareMetalAPI0().wsgi
api1 = BareMetalAPI1().wsgi
api2 = BareMetalAPI2().wsgi


def test0():
    def start_response(status, headers):
        assert status == '429 Too Many Requests'
    api0({}, start_response)


def test1():
    def start_response(status, headers):
        assert status == '429 Too Many Requests'
    api1({}, start_response)


def test2():
    def start_response(status, headers):
        assert status == '429 Too Many Requests'
    api2({}, start_response)


if __name__ == '__main__':
    # Exercise for the reader: use argparse or similar
    assert len(sys.argv) == 2, "{} bench/run".format(sys.argv[0])
    command = sys.argv[1]
    assert command in ('bench', 'run'), "{} bench/run".format(sys.argv[0])

    if command == 'bench':
        print(timeit.timeit(test0))
        print(timeit.timeit(test1))
        print(timeit.timeit(test2))
    else:
        validator_app = validator(api2)
        httpd = make_server('', 8000, validator_app)
        print('Listening on port 8000...')
        httpd.serve_forever()
```

A good idea!

When using falcon, I like `int` as the status. But falcon does not support it, so I wrap it.
```python
import falcon

STATUS_CODES = {}
for v in vars(falcon.status_codes).values():
    if isinstance(v, str):
        try:
            code = int(v.split(maxsplit=1)[0])
            STATUS_CODES[code] = v
        except Exception:
            pass

class Resource(object):
    def status(self, code):
        return STATUS_CODES[code]

    def send_response(self, resp, status=200, body=None, content_type=None):
        resp.status = self.status(status)
        if content_type:
            resp.content_type = content_type
        if body:
            resp.body = body

   def on_get(self, req, resp):
        self.send_response(resp)
```

But, it's tedious. If falcon supports it natively, it's perfect!
Resuming work on this!
An alternative approach to this that I think has worked out pretty well on the ASGI side:

Use the new `falcon.util.misc.http_status_to_code()` method from #1573 to convert the value so that it can be used in the response. It uses `functools.lru_cache` to make it blazing fast.

We'll also need to update the docs to let users know that they can now set `resp.status` to anything that `http_status_to_code()` accepts (we may want to update the tutorials/examples as well to use instances of http.HTTPStatus from the standard library).
I'm assigning myself for the time being, if anyone wants to help out, feel free to volunteer!
Otherwise I'll try to squeeze this in for the 3.0 release myself.
@kgriffs have you read upon [`functools.lru_cache` thread safety issues](https://bugs.python.org/issue28969) btw?

As I understand the problem is fixed on the newest versions of CPython, and probably backported even to down to 3.5? Might it still be an issue if popular distributions still ship the unpatched variant?

FWIW the default Python 3 on my Ubuntu 16.04 workstation is 3.5.2, and I could unfortunately reproduce the issue immediately with one of the attached test cases:
```python
import functools
import threading
import time


@functools.lru_cache(maxsize=2)
def f(v):
    time.sleep(.01)


threads = [threading.Thread(target=f, args=(v,)) for v in [1,2,2,3,2]]
for t in threads:
    t.start()
```

```
Exception in thread Thread-5:
Traceback (most recent call last):
  File "/usr/lib/python3.5/threading.py", line 914, in _bootstrap_inner
    self.run()
  File "/usr/lib/python3.5/threading.py", line 862, in run
    self._target(*self._args, **self._kwargs)
KeyError: (2,)
```

16.04 is still pretty ubiquitous in the server world, I think even our Travis uses that (although Travis copies over newest Python tarballs).
Ugh, that's awful. Based on some quick tests, it looks like it is fixed as of 3.5.4+, 3.6.1+, 3.7.0+ and 3.8.0. I suppose we could detect the version and then swap out the decorator for a NOP function as needed?

Resolving patch:
diff --git a/falcon/app.py b/falcon/app.py
--- a/falcon/app.py
+++ b/falcon/app.py
@@ -29,6 +29,7 @@
 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 @@ def __call__(self, env, start_response):  # noqa: C901
 
             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
--- 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
--- a/falcon/util/misc.py
+++ b/falcon/util/misc.py
@@ -56,6 +56,8 @@
     '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
--- 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 @@ def test_body_is_set(self, body_client):
         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
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -415,8 +415,19 @@ def test_get_http_status(self):
             (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 @@ def test_code_to_http_status(self, v_in, v_out):
 
     @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)