feat(Request): Add support for several proxy "forwarded" headers (#1083)

Add support for some "forwarded" headers, included several new
attributes and a reworking of some of the existing code to better
facilitate sharing and performance.

Also clean up a couple tiny nits in the docstrings for the sake of
consistency.
This commit is contained in:
Kurt Griffiths 2017-07-17 15:43:06 -06:00 committed by John Vrbanac
parent c50a89d428
commit b51d4e9a35
12 changed files with 535 additions and 83 deletions

View File

@ -1,3 +1,24 @@
1.3.0
=====
Breaking Changes
----------------
(None)
New & Improved
--------------
- A number of properties were added to ``falcon.Request`` to
expose information added by proxies in front of the application
server. These include the `forwarded`, `forwarded_uri`,
`forwarded_scheme`, `forwarded_host`, and `forwarded_prefix`
properties. The `prefix` attribute was also added as part of this
work.
Fixed
-----
1.2.0
=====

View File

@ -23,6 +23,9 @@ Request
.. autoclass:: falcon.Request
:members:
.. autoclass:: falcon.Forwarded
:members:
Response
--------

20
docs/changes/1.3.0.rst Normal file
View File

@ -0,0 +1,20 @@
Changelog for Falcon 1.3.0
==========================
Breaking Changes
----------------
(None)
New & Improved
--------------
- A number of attributes were added to :class:`falcon.Request` to
expose information added by proxies in front of the application
server. These include the `forwarded`, `forwarded_uri`,
`forwarded_scheme`, `forwarded_host`, and `forwarded_prefix`
attribute. The `prefix` attribute was also added as part of this
work.
Fixed
-----

View File

@ -3,6 +3,7 @@ Changelogs
.. toctree::
1.3.0 <1.3.0>
1.2.0 <1.2.0>
1.1.0 <1.1.0>
1.0.0 <1.0.0>

View File

@ -54,5 +54,5 @@ import falcon.uri # NOQA
from falcon.util import * # NOQA
from falcon.hooks import before, after # NOQA
from falcon.request import Request, RequestOptions # NOQA
from falcon.request import Request, RequestOptions, Forwarded # NOQA
from falcon.response import Response, ResponseOptions # NOQA

View File

@ -7,7 +7,7 @@ import six
class BaseHandler(object):
"""Abstract Base Class for an internet media type handler"""
@abc.abstractmethod
@abc.abstractmethod # pragma: no cover
def serialize(self, obj):
"""Serialize the media object on a :any:`falcon.Response`
@ -18,7 +18,7 @@ class BaseHandler(object):
bytes: The resulting serialized bytes from the input object.
"""
@abc.abstractmethod
@abc.abstractmethod # pragma: no cover
def deserialize(self, raw):
"""Deserialize the :any:`falcon.Request` body.

View File

@ -74,12 +74,59 @@ class Request(object):
options (dict): Set of global options passed from the API handler.
Attributes:
scheme (str): Either 'http' or 'https'.
scheme (str): URL scheme used for the request. Either 'http' or
'https'.
Note:
If the request was proxied, the scheme may not
match what was originally requested by the client.
:py:attr:`forwarded_scheme` can be used, instead,
to handle such cases.
forwarded_scheme (str): Original URL scheme requested by the
user agent, if the request was proxied. Typical values are
'http' or 'https'.
The following request headers are checked, in order of
preference, to determine the forwarded scheme:
- ``Forwarded``
- ``X-Forwarded-For``
If none of these headers are available, or if the
Forwarded header is available but does not contain a
"proto" parameter in the first hop, the value of
:attr:`scheme` is returned instead.
(See also: RFC 7239, Section 1)
protocol (str): Deprecated alias for `scheme`. Will be removed
in a future release.
method (str): HTTP method requested (e.g., 'GET', 'POST', etc.)
host (str): Hostname requested by the client
port (int): Port used for the request. If the request URL does
host (str): Host request header field
forwarded_host (str): Original host request header as received
by the first proxy in front of the application server.
The following request headers are checked, in order of
preference, to determine the forwarded scheme:
- ``Forwarded``
- ``X-Forwarded-Host``
If none of the above headers are available, or if the
Forwarded header is available but the "host"
parameter is not included in the first hop, the value of
:attr:`host` is returned instead.
Note:
Reverse proxies are often configured to set the Host
header directly to the one that was originally
requested by the user agent; in that case, using
:attr:`host` is sufficient.
(See also: RFC 7239, Section 4)
port (int): Port used for the request. If the request URI does
not specify a port, the default one for the given schema is
returned (80 for HTTP and 443 for HTTPS).
netloc (str): Returns the 'host:port' portion of the request
@ -94,9 +141,15 @@ class Request(object):
for `subdomain` is undefined.
env (dict): Reference to the WSGI environ ``dict`` passed in from the
server. See also PEP-3333.
app (str): Name of the WSGI app (if using WSGI's notion of virtual
hosting).
server. (See also PEP-3333.)
app (str): The initial portion of the request URI's path that
corresponds to the application object, so that the
application knows its virtual "location". This may be an
empty string, if the application corresponds to the "root"
of the server.
(Corresponds to the "SCRIPT_NAME" environ variable defined
by PEP-3333.)
access_route(list): IP address of the original client, as well
as any known addresses of proxies fronting the WSGI server.
@ -152,15 +205,26 @@ class Request(object):
the current Request instance. Therefore the first argument is
the Request instance itself (self).
uri (str): The fully-qualified URI for the request.
url (str): alias for `uri`.
relative_uri (str): The path + query string portion of the full URI.
path (str): Path portion of the request URL (not including query
url (str): Alias for `uri`.
forwarded_uri (str): Original URI for proxied requests. Uses
:attr:`forwarded_scheme` and :attr:`forwarded_host` in
order to reconstruct the original URI requested by the user
agent.
relative_uri (str): The path and query string portion of the
request URI, omitting the scheme and host.
prefix (str): The prefix of the request URI, including scheme,
host, and WSGI app (if any).
forwarded_prefix (str): The prefix of the original URI for
proxied requests. Uses :attr:`forwarded_scheme` and
:attr:`forwarded_host` in order to reconstruct the
original URI.
path (str): Path portion of the request URI (not including query
string).
Note:
`req.path` may be set to a new value by a `process_request()`
middleware method in order to influence routing.
query_string (str): Query string portion of the request URL, without
query_string (str): Query string portion of the request URI, without
the preceding '?' character.
uri_template (str): The template for the route that was matched for
this request. May be ``None`` if the request has not yet been
@ -168,7 +232,11 @@ class Request(object):
methods. May also be ``None`` if your app uses a custom routing
engine and the engine does not provide the URI template when
resolving a route.
forwarded (list): Value of the Forwarded header, as a parsed list
of :class:`falcon.Forwarded` objects, or ``None`` if the header
is missing.
(See also: RFC 7239, Section 4)
user_agent (str): Value of the User-Agent header, or ``None`` if the
header is missing.
referer (str): Value of Referer header, or ``None`` if
@ -316,23 +384,27 @@ class Request(object):
"""
__slots__ = (
'__dict__',
'_bounded_stream',
'_cached_access_route',
'_cached_forwarded',
'_cached_forwarded_prefix',
'_cached_forwarded_uri',
'_cached_headers',
'_cached_uri',
'_cached_prefix',
'_cached_relative_uri',
'_cached_uri',
'_cookies',
'_params',
'_wsgierrors',
'content_type',
'context',
'env',
'method',
'_params',
'options',
'path',
'query_string',
'stream',
'_bounded_stream',
'context',
'_wsgierrors',
'options',
'_cookies',
'_cached_access_route',
'__dict__',
'uri_template',
'_media',
)
@ -388,10 +460,14 @@ class Request(object):
self._cookies = None
self._cached_headers = None
self._cached_uri = None
self._cached_relative_uri = None
self._cached_access_route = None
self._cached_forwarded = None
self._cached_forwarded_prefix = None
self._cached_forwarded_uri = None
self._cached_headers = None
self._cached_prefix = None
self._cached_relative_uri = None
self._cached_uri = None
try:
self.content_type = self.env['CONTENT_TYPE']
@ -457,6 +533,70 @@ class Request(object):
referer = helpers.header_property('HTTP_REFERER')
@property
def forwarded(self):
# PERF(kgriffs): We could DRY up this memoization pattern using
# a decorator, but that would incur additional overhead without
# resorting to some trickery to rewrite the body of the method
# itself (vs. simply wrapping it with some memoization logic).
# At some point we might look into this but I don't think
# it's worth it right now.
if self._cached_forwarded is None:
# PERF(kgriffs): If someone is calling this, they are probably
# confident that the header exists, so most of the time we
# expect this call to succeed. Therefore, we won't need to
# pay the penalty of a raised exception in most cases, and
# there is no need to spend extra cycles calling get() or
# checking beforehand whether the key is in the dict.
try:
forwarded = self.env['HTTP_FORWARDED']
except KeyError:
return None
parsed_elements = []
for element in forwarded.split(','):
parsed_element = Forwarded()
# NOTE(kgriffs): Calling strip() is necessary here since
# "an HTTP list allows white spaces to occur between the
# identifiers" (see also RFC 7239, Section 7.1).
for param in element.strip().split(';'):
# PERF(kgriffs): partition() is faster than split().
name, __, value = param.partition('=')
if not value:
# NOTE(kgriffs): The '=' separator was not found or
# the value was missing. Ignore this malformed
# param.
continue
# NOTE(kgriffs): According to RFC 7239, parameter
# names are case-insensitive.
name = name.lower()
value = unquote_string(value)
if name == 'by':
parsed_element.dest = value
elif name == 'for':
parsed_element.src = value
elif name == 'host':
parsed_element.host = value
elif name == 'proto':
# NOTE(kgriffs): RFC 7239 only requires that
# the "proto" value conform to the Host ABNF
# described in RFC 7230. The Host ABNF, in turn,
# does not require that the scheme be in any
# particular case, so we normalize it here to be
# consistent with the WSGI spec that *does*
# require the value of 'wsgi.url_scheme' to be
# either 'http' or 'https' (case-sensitive).
parsed_element.scheme = value.lower()
parsed_elements.append(parsed_element)
self._cached_forwarded = parsed_elements
return self._cached_forwarded
@property
def client_accepts_json(self):
return self.client_accepts('application/json')
@ -579,30 +719,56 @@ class Request(object):
@property
def app(self):
return self.env.get('SCRIPT_NAME', '')
# PERF(kgriffs): try..except is faster than get() assuming that
# we normally expect the key to exist. Even though PEP-3333
# allows WSGI servers to omit the key when the value is an
# empty string, uwsgi, gunicorn, waitress, and wsgiref all
# include it even in that case.
try:
return self.env['SCRIPT_NAME']
except KeyError:
return ''
@property
def scheme(self):
return self.env['wsgi.url_scheme']
@property
def forwarded_scheme(self):
# PERF(kgriffs): Since the Forwarded header is still relatively
# new, we expect X-Forwarded-Proto to be more common, so
# try to avoid calling self.forwarded if we can, since it uses a
# try...catch that will usually result in a relatively expensive
# raised exception.
if 'HTTP_FORWARDED' in self.env:
first_hop = self.forwarded[0]
scheme = first_hop.scheme or self.scheme
else:
# PERF(kgriffs): This call should normally succeed, so
# just go for it without wasting time checking it
# first. Note also that the indexing operator is
# slightly faster than using get().
try:
scheme = self.env['HTTP_X_FORWARDED_PROTO'].lower()
except KeyError:
scheme = self.env['wsgi.url_scheme']
return scheme
# TODO(kgriffs): Remove this deprecated alias in Falcon 2.0
protocol = scheme
@property
def uri(self):
if self._cached_uri is None:
protocol = self.env['wsgi.url_scheme']
scheme = self.env['wsgi.url_scheme']
# PERF: For small numbers of items, '+' is faster
# than ''.join(...). Concatenation is also generally
# faster than formatting.
value = (protocol + '://' +
value = (scheme + '://' +
self.netloc +
self.app +
self.path)
if self.query_string:
value = value + '?' + self.query_string
self.relative_uri)
self._cached_uri = value
@ -610,6 +776,53 @@ class Request(object):
url = uri
@property
def forwarded_uri(self):
if self._cached_forwarded_uri is None:
# PERF: For small numbers of items, '+' is faster
# than ''.join(...). Concatenation is also generally
# faster than formatting.
value = (self.forwarded_scheme + '://' +
self.forwarded_host +
self.relative_uri)
self._cached_forwarded_uri = value
return self._cached_forwarded_uri
@property
def relative_uri(self):
if self._cached_relative_uri is None:
if self.query_string:
self._cached_relative_uri = (self.app + self.path + '?' +
self.query_string)
else:
self._cached_relative_uri = self.app + self.path
return self._cached_relative_uri
@property
def prefix(self):
if self._cached_prefix is None:
self._cached_prefix = (
self.env['wsgi.url_scheme'] + '://' +
self.netloc +
self.app
)
return self._cached_prefix
@property
def forwarded_prefix(self):
if self._cached_forwarded_prefix is None:
self._cached_forwarded_prefix = (
self.forwarded_scheme + '://' +
self.forwarded_host +
self.app
)
return self._cached_forwarded_prefix
@property
def host(self):
try:
@ -625,23 +838,34 @@ class Request(object):
return host
@property
def forwarded_host(self):
# PERF(kgriffs): Since the Forwarded header is still relatively
# new, we expect X-Forwarded-Host to be more common, so
# try to avoid calling self.forwarded if we can, since it uses a
# try...catch that will usually result in a relatively expensive
# raised exception.
if 'HTTP_FORWARDED' in self.env:
first_hop = self.forwarded[0]
host = first_hop.host or self.host
else:
# PERF(kgriffs): This call should normally succeed, assuming
# that the caller is expecting a forwarded header, so
# just go for it without wasting time checking it
# first.
try:
host = self.env['HTTP_X_FORWARDED_HOST']
except KeyError:
host = self.host
return host
@property
def subdomain(self):
# PERF(kgriffs): .partition is slightly faster than .split
subdomain, sep, remainder = self.host.partition('.')
return subdomain if sep else None
@property
def relative_uri(self):
if self._cached_relative_uri is None:
if self.query_string:
self._cached_relative_uri = (self.app + self.path + '?' +
self.query_string)
else:
self._cached_relative_uri = self.app + self.path
return self._cached_relative_uri
@property
def headers(self):
# NOTE(kgriffs: First time here will cache the dict so all we
@ -696,7 +920,11 @@ class Request(object):
# aware that an upstream proxy is malfunctioning.
if 'HTTP_FORWARDED' in self.env:
self._cached_access_route = self._parse_rfc_forwarded()
self._cached_access_route = []
for hop in self.forwarded:
if hop.src is not None:
host, __ = parse_host(hop.src)
self._cached_access_route.append(host)
elif 'HTTP_X_FORWARDED_FOR' in self.env:
addresses = self.env['HTTP_X_FORWARDED_FOR'].split(',')
self._cached_access_route = [ip.strip() for ip in addresses]
@ -738,7 +966,8 @@ class Request(object):
# NOTE(kgriffs): According to PEP-3333 we should first
# try to use the Host header if present.
#
# PERF(kgriffs): try..except is faster than .get
# PERF(kgriffs): try..except is faster than get() when we
# expect the key to be present most of the time.
try:
netloc_value = env['HTTP_HOST']
except KeyError:
@ -1383,33 +1612,6 @@ class Request(object):
self._params.update(extra_params)
def _parse_rfc_forwarded(self):
"""Parse RFC 7239 "Forwarded" header.
Returns:
list: addresses derived from "for" parameters.
"""
addr = []
for forwarded in self.env['HTTP_FORWARDED'].split(','):
for param in forwarded.split(';'):
# PERF(kgriffs): Partition() is faster than split().
key, _, val = param.strip().partition('=')
if not val:
# NOTE(kgriffs): The '=' separator was not found or
# it was, but the value was missing.
continue
if key.lower() != 'for':
# We only want "for" params
continue
host, _ = parse_host(unquote_string(val))
addr.append(host)
return addr
# PERF: To avoid typos and improve storage space and speed over a dict.
class RequestOptions(object):
@ -1495,3 +1697,36 @@ class RequestOptions(object):
self.strip_url_path_trailing_slash = True
self.default_media_type = DEFAULT_MEDIA_TYPE
self.media_handlers = Handlers()
class Forwarded(object):
"""Represents a parsed Forwarded header.
(See also: RFC 7239, Section 4)
Attributes:
src (str): The value of the "for" parameter, or
``None`` if the parameter is absent. Identifies the
node making the request to the proxy.
dest (str): The value of the "by" parameter, or
``None`` if the parameter is absent. Identifies the
client-facing interface of the proxy.
host (str): The value of the "host" parameter, or
``None`` if the parameter is absent. Provides the host
request header field as received by the proxy.
scheme (str): The value of the "proto" parameter, or
``None`` if the parameter is absent. Indicates the
protocol that was used to make the request to
the proxy.
"""
# NOTE(kgriffs): Use "client" since "for" is a keyword, and
# "scheme" instead of "proto" to be consistent with the
# falcon.Request interface.
__slots__ = ('src', 'dest', 'host', 'scheme')
def __init__(self):
self.src = None
self.dest = None
self.host = None
self.scheme = None

View File

@ -157,6 +157,12 @@ def create_environ(path='/', query_string='', protocol='HTTP/1.1',
else:
port = str(port)
# NOTE(kgriffs): Judging by the algorithm given in PEP-3333 for
# reconstructing the URL, SCRIPT_NAME is expected to contain a
# preceding slash character.
if app and not app.startswith('/'):
app = '/' + app
env = {
'SERVER_PROTOCOL': protocol,
'SERVER_SOFTWARE': 'gunicorn/0.17.0',

View File

@ -1,4 +1,5 @@
import datetime
import itertools
import pytest
import six
@ -11,7 +12,7 @@ import falcon.uri
_PROTOCOLS = ['HTTP/1.0', 'HTTP/1.1']
class TestReqVars(object):
class TestRequestAttributes(object):
def setup_method(self, method):
self.qs = 'marker=deadbeef&limit=10'
@ -105,10 +106,12 @@ class TestReqVars(object):
path = req.path
query_string = req.query_string
expected_uri = ''.join([scheme, '://', host, app, path,
'?', query_string])
expected_prefix = ''.join([scheme, '://', host, app])
expected_uri = ''.join([expected_prefix, path, '?', query_string])
assert expected_uri == req.uri
assert req.uri == expected_uri
assert req.prefix == expected_prefix
assert req.prefix == expected_prefix # Check cached value
@pytest.mark.skipif(not six.PY3, reason='Test only applies to Python 3')
@pytest.mark.parametrize('test_path', [
@ -141,10 +144,11 @@ class TestReqVars(object):
assert req.path == falcon.uri.decode(test_path)
def test_uri(self):
uri = ('http://' + testing.DEFAULT_HOST + ':8080' +
self.app + self.relative_uri)
prefix = 'http://' + testing.DEFAULT_HOST + ':8080' + self.app
uri = prefix + self.relative_uri
assert self.req.url == uri
assert self.req.prefix == prefix
# NOTE(kgriffs): Call twice to check caching works
assert self.req.uri == uri
@ -695,20 +699,35 @@ class TestReqVars(object):
assert req.scheme == scheme
assert req.port == 443
@pytest.mark.parametrize('protocol', _PROTOCOLS)
def test_scheme_http(self, protocol):
@pytest.mark.parametrize(
'protocol, set_forwarded_proto',
list(itertools.product(_PROTOCOLS, [True, False]))
)
def test_scheme_http(self, protocol, set_forwarded_proto):
scheme = 'http'
forwarded_scheme = 'HttPs'
headers = dict(self.headers)
if set_forwarded_proto:
headers['X-Forwarded-Proto'] = forwarded_scheme
req = Request(testing.create_environ(
protocol=protocol,
scheme=scheme,
app=self.app,
path='/hello',
query_string=self.qs,
headers=self.headers))
headers=headers))
assert req.scheme == scheme
assert req.port == 80
if set_forwarded_proto:
assert req.forwarded_scheme == forwarded_scheme.lower()
else:
assert req.forwarded_scheme == scheme
@pytest.mark.parametrize('protocol', _PROTOCOLS)
def test_netloc_default_port(self, protocol):
req = Request(testing.create_environ(
@ -750,6 +769,21 @@ class TestReqVars(object):
assert req.port == port
assert req.netloc == '{0}:{1}'.format(host, port)
def test_app_present(self):
req = Request(testing.create_environ(app='/moving-pictures'))
assert req.app == '/moving-pictures'
def test_app_blank(self):
req = Request(testing.create_environ(app=''))
assert req.app == ''
def test_app_missing(self):
env = testing.create_environ()
del env['SCRIPT_NAME']
req = Request(env)
assert req.app == ''
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------

View File

@ -0,0 +1,122 @@
from falcon.request import Request
import falcon.testing as testing
def test_no_forwarded_headers():
req = Request(testing.create_environ(
host='example.com',
path='/languages',
app='backoffice'
))
assert req.forwarded is None
assert req.forwarded_uri == req.uri
assert req.forwarded_uri == 'http://example.com/backoffice/languages'
assert req.forwarded_prefix == 'http://example.com/backoffice'
def test_x_forwarded_host():
req = Request(testing.create_environ(
host='suchproxy.suchtesting.com',
path='/languages',
headers={'X-Forwarded-Host': 'something.org'}
))
assert req.forwarded is None
assert req.forwarded_host == 'something.org'
assert req.forwarded_uri != req.uri
assert req.forwarded_uri == 'http://something.org/languages'
assert req.forwarded_prefix == 'http://something.org'
assert req.forwarded_prefix == 'http://something.org' # Check cached value
def test_x_forwarded_proto():
req = Request(testing.create_environ(
host='example.org',
path='/languages',
headers={'X-Forwarded-Proto': 'HTTPS'}
))
assert req.forwarded is None
assert req.forwarded_scheme == 'https'
assert req.forwarded_uri != req.uri
assert req.forwarded_uri == 'https://example.org/languages'
assert req.forwarded_prefix == 'https://example.org'
def test_forwarded_host():
req = Request(testing.create_environ(
host='suchproxy02.suchtesting.com',
path='/languages',
headers={
'Forwarded': 'host=something.org , host=suchproxy01.suchtesting.com'
}
))
assert req.forwarded is not None
for f in req.forwarded:
assert f.src is None
assert f.dest is None
assert f.scheme is None
assert req.forwarded[0].host == 'something.org'
assert req.forwarded[1].host == 'suchproxy01.suchtesting.com'
assert req.forwarded_host == 'something.org'
assert req.forwarded_uri != req.uri
assert req.forwarded_uri == 'http://something.org/languages'
assert req.forwarded_prefix == 'http://something.org'
def test_forwarded_multiple_params():
req = Request(testing.create_environ(
host='suchproxy02.suchtesting.com',
path='/languages',
headers={
'Forwarded': (
'host=something.org;proto=hTTps;ignore=me;for=108.166.30.185, '
'by=203.0.113.43;host=suchproxy01.suchtesting.com;proto=httP'
)
}
))
assert req.forwarded is not None
assert req.forwarded[0].host == 'something.org'
assert req.forwarded[0].scheme == 'https'
assert req.forwarded[0].src == '108.166.30.185'
assert req.forwarded[0].dest is None
assert req.forwarded[1].host == 'suchproxy01.suchtesting.com'
assert req.forwarded[1].scheme == 'http'
assert req.forwarded[1].src is None
assert req.forwarded[1].dest == '203.0.113.43'
assert req.forwarded_scheme == 'https'
assert req.forwarded_host == 'something.org'
assert req.forwarded_uri != req.uri
assert req.forwarded_uri == 'https://something.org/languages'
assert req.forwarded_prefix == 'https://something.org'
def test_forwarded_missing_first_hop_host():
req = Request(testing.create_environ(
host='suchproxy02.suchtesting.com',
path='/languages',
app='doge',
headers={
'Forwarded': 'for=108.166.30.185,host=suchproxy01.suchtesting.com'
}
))
assert req.forwarded[0].host is None
assert req.forwarded[0].src == '108.166.30.185'
assert req.forwarded[1].host == 'suchproxy01.suchtesting.com'
assert req.forwarded[1].src is None
assert req.forwarded_scheme == 'http'
assert req.forwarded_host == 'suchproxy02.suchtesting.com'
assert req.forwarded_uri == req.uri
assert req.forwarded_uri == 'http://suchproxy02.suchtesting.com/doge/languages'
assert req.forwarded_prefix == 'http://suchproxy02.suchtesting.com/doge'

12
tox.ini
View File

@ -199,7 +199,17 @@ commands = uwsgi --http localhost:8000 --wsgi-file {toxinidir}/tests/dump_wsgi.p
[testenv:py27_dump_gunicorn]
basepython = python2.7
deps = gunicorn
commands = gunicorn: gunicorn -b localhost:8000 tests.dump_wsgi
commands = gunicorn -b localhost:8000 tests.dump_wsgi
[testenv:py36_dump_gunicorn]
basepython = python3.6
deps = gunicorn
commands = gunicorn -b localhost:8000 tests.dump_wsgi
[testenv:py27_dump_waitress]
basepython = python2.7
deps = waitress
commands = waitress-serve --listen=localhost:8000 tests.dump_wsgi:application
[testenv:py27_dump_wsgiref]
basepython = python2.7