├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.rst ├── requirements.txt ├── runtests.py ├── setup.py ├── tests.py └── wsgiadapter.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .cache 4 | dist/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | - "3.6" 7 | script: make test 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Sean Brant and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the requests-wsgi-adapter nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | develop: 2 | pip install -e . 3 | pip install "file://`pwd`#egg=wsgiadapter[tests]" 4 | 5 | lint: 6 | @echo "Linting Python files" 7 | # flake8 is not Python 2.6 compatible and Travis runs this anyway for the 8 | # rest of Python versions 9 | python -c "import sys; sys.exit(0 if sys.version_info >= (2,7) else 1)" && flake8 --ignore=E501,E225,E121,E123,E124,E125,E127,E128 wsgiadapter.py || echo "not linting on python 2.6" 10 | @echo "" 11 | 12 | test-python: 13 | @echo "Running Python tests" 14 | python setup.py -q test || exit 1 15 | @echo "" 16 | 17 | test: develop lint test-python 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. code-block:: python 2 | 3 | >>> from django.core.wsgi import get_wsgi_application 4 | >>> 5 | >>> import requests 6 | >>> import wsgiadapter 7 | >>> 8 | >>> s = requests.Session() 9 | >>> s.mount('http://staging/', wsgiadapter.WSGIAdapter(get_wsgi_application())) 10 | >>> s.get('http://staging/index') 11 | 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=1.0 2 | pytest 3 | flake8 4 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | 5 | def runtests(args=None): 6 | import pytest 7 | 8 | if not args: 9 | args = [] 10 | 11 | if not any(a for a in args[1:] if not a.startswith("-")): 12 | args.append("tests.py") 13 | 14 | sys.exit(pytest.main(args)) 15 | 16 | 17 | if __name__ == "__main__": 18 | runtests(sys.argv) 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | 5 | setup( 6 | name="requests-wsgi-adapter", 7 | version="0.4.1", 8 | description="WSGI Transport Adapter for Requests", 9 | long_description=open("README.rst").read(), 10 | author="Sean Brant", 11 | author_email="brant.sean@gmail.com", 12 | url="https://github.com/seanbrant/requests-wsgi-adapter", 13 | license="BSD", 14 | py_modules=["wsgiadapter"], 15 | test_suite="runtests.runtests", 16 | install_requires=["requests>=1.0"], 17 | extras_require={"tests": ["flake8", "pytest"]}, 18 | classifiers=[ 19 | "Development Status :: 4 - Beta", 20 | "Environment :: Web Environment", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: BSD License", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python", 25 | ], 26 | ) 27 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import io 3 | import json 4 | import unittest 5 | 6 | import requests 7 | from urllib3._collections import HTTPHeaderDict 8 | 9 | from wsgiadapter import WSGIAdapter 10 | 11 | 12 | class WSGITestHandler(object): 13 | def __init__(self, extra_headers=None): 14 | self.extra_headers = extra_headers or tuple() 15 | 16 | def __call__(self, environ, start_response): 17 | headers = HTTPHeaderDict({"Content-Type": "application/json"}) 18 | for key, value in self.extra_headers: 19 | headers.add(key, value) 20 | start_response("200 OK", headers, exc_info=None) 21 | return [ 22 | bytes( 23 | json.dumps( 24 | { 25 | "result": "__works__", 26 | "body": environ["wsgi.input"].read().decode("utf-8"), 27 | "content_type": environ["CONTENT_TYPE"], 28 | "content_length": environ["CONTENT_LENGTH"], 29 | "path_info": environ["PATH_INFO"] 30 | .encode("latin-1") 31 | .decode("utf-8"), 32 | "script_name": environ["SCRIPT_NAME"], 33 | "request_method": environ["REQUEST_METHOD"], 34 | "server_name": environ["SERVER_NAME"], 35 | "server_port": environ["SERVER_PORT"], 36 | } 37 | ).encode("utf-8") 38 | ) 39 | ] 40 | 41 | 42 | class WSGIAdapterTest(unittest.TestCase): 43 | def setUp(self): 44 | self.session = requests.session() 45 | adapter = WSGIAdapter(app=WSGITestHandler()) 46 | self.session.mount("http://localhost", adapter) 47 | self.session.mount("http://localhost:5000", adapter) 48 | self.session.mount("https://localhost", adapter) 49 | self.session.mount("https://localhost:5443", adapter) 50 | 51 | def test_basic_response(self): 52 | response = self.session.get( 53 | "http://localhost/index", headers={"Content-Type": "application/json"} 54 | ) 55 | self.assertEqual(response.status_code, 200) 56 | self.assertEqual(response.headers["Content-Type"], "application/json") 57 | self.assertEqual(response.json()["result"], "__works__") 58 | self.assertEqual(response.json()["content_type"], "application/json") 59 | self.assertEqual(response.json()["script_name"], "") 60 | self.assertEqual(response.json()["path_info"], "/index") 61 | self.assertEqual(response.json()["request_method"], "GET") 62 | self.assertEqual(response.json()["server_name"], "localhost") 63 | 64 | def test_request_with_body(self): 65 | response = self.session.post("http://localhost/index", data="__test__") 66 | self.assertEqual(response.json()["body"], "__test__") 67 | self.assertEqual(response.json()["content_length"], len("__test__")) 68 | 69 | def test_request_with_https(self): 70 | response = self.session.get("https://localhost/index") 71 | self.assertEqual(response.json()["server_port"], "443") 72 | 73 | def test_request_with_json(self): 74 | response = self.session.post("http://localhost/index", json={}) 75 | self.assertEqual(response.json()["body"], "{}") 76 | self.assertEqual(response.json()["content_length"], len("{}")) 77 | 78 | def test_request_i18n_path(self): 79 | response = self.session.get("http://localhost/привет", json={}) 80 | self.assertEqual(response.json()["path_info"], u"/привет") 81 | response = self.session.get("http://localhost/Moselfränkisch", json={}) 82 | self.assertEqual(response.json()["path_info"], u"/Moselfränkisch") 83 | 84 | def test_server_port(self): 85 | response = self.session.get("http://localhost/index") 86 | self.assertEqual(response.json()["server_port"], "80") 87 | response = self.session.get("http://localhost:5000/index") 88 | self.assertEqual(response.json()["server_port"], "5000") 89 | response = self.session.get("https://localhost/index") 90 | self.assertEqual(response.json()["server_port"], "443") 91 | response = self.session.get("https://localhost:5443/index") 92 | self.assertEqual(response.json()["server_port"], "5443") 93 | 94 | def test_plain_text(self): 95 | response = self.session.post( 96 | "http://localhost/index", data="Once upon a time..." 97 | ) 98 | self.assertEqual(response.json()["body"], "Once upon a time...") 99 | 100 | def test_blob(self): 101 | response = self.session.post("http://localhost/index", data=b"bliblob") 102 | self.assertEqual(response.json()["body"], "bliblob") 103 | 104 | def test_empty(self): 105 | response = self.session.post("http://localhost/index") 106 | self.assertEqual(response.json()["body"], "") 107 | 108 | def test_stream_download(self): 109 | with self.session.get("http://localhost/index", stream=True) as response: 110 | self.assertEqual(response.status_code, 200) 111 | # payload = json.load(response.raw) 112 | payload = json.loads(response.raw.read().decode("utf-8")) 113 | self.assertEqual(payload["result"], "__works__") 114 | 115 | def test_stream_upload(self): 116 | with io.BytesIO(b"hugeblob") as f: 117 | response = self.session.post("http://localhost/index", data=f) 118 | self.assertEqual(response.status_code, 200) 119 | self.assertEqual(response.json()["body"], "hugeblob") 120 | self.assertEqual(response.json()["content_length"], len(b"hugeblob")) 121 | 122 | 123 | class WSGIAdapterCookieTest(unittest.TestCase): 124 | def setUp(self): 125 | app = WSGITestHandler( 126 | extra_headers=[ 127 | ("Set-Cookie", "c1=v1; Path=/"), 128 | ("Set-Cookie", "c2=v2; Path=/"), 129 | ] 130 | ) 131 | self.session = requests.session() 132 | self.session.mount("http://localhost", WSGIAdapter(app=app)) 133 | self.session.mount("https://localhost", WSGIAdapter(app=app)) 134 | 135 | def test_request_with_cookies(self): 136 | response = self.session.get("http://localhost/cookies") 137 | self.assertEqual(response.cookies["c1"], "v1") 138 | self.assertEqual(self.session.cookies["c1"], "v1") 139 | 140 | def test_multiple_cookies(self): 141 | app = WSGITestHandler( 142 | extra_headers=[ 143 | ("Set-Cookie", "flimble=floop; Path=/"), 144 | ("Set-Cookie", "flamble=flaap; Path=/"), 145 | ] 146 | ) 147 | session = requests.session() 148 | session.mount("http://localhost", WSGIAdapter(app=app)) 149 | 150 | session.get("http://localhost/cookies/set?flimble=floop&flamble=flaap") 151 | self.assertEqual(session.cookies["flimble"], "floop") 152 | self.assertEqual(session.cookies["flamble"], "flaap") 153 | 154 | def test_delete_cookies(self): 155 | session = requests.session() 156 | set_app = WSGITestHandler( 157 | extra_headers=[ 158 | ("Set-Cookie", "flimble=floop; Path=/"), 159 | ("Set-Cookie", "flamble=flaap; Path=/"), 160 | ] 161 | ) 162 | delete_app = WSGITestHandler( 163 | extra_headers=[ 164 | ( 165 | "Set-Cookie", 166 | "flimble=; Expires=Thu, 01-Jan-1970 00:00:00 GMT; Max-Age=0; Path=/", 167 | ) 168 | ] 169 | ) 170 | session.mount("http://localhost/cookies/set", WSGIAdapter(app=set_app)) 171 | session.mount("http://localhost/cookies/delete", WSGIAdapter(app=delete_app)) 172 | 173 | session.get("http://localhost/cookies/set?flimble=floop&flamble=flaap") 174 | self.assertEqual(session.cookies["flimble"], "floop") 175 | self.assertEqual(session.cookies["flamble"], "flaap") 176 | 177 | session.get("http://localhost/cookies/delete?flimble") 178 | self.assertNotIn("flimble", session.cookies) 179 | self.assertEqual(session.cookies["flamble"], "flaap") 180 | -------------------------------------------------------------------------------- /wsgiadapter.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import io 3 | import logging 4 | 5 | from urllib3._collections import HTTPHeaderDict 6 | 7 | from requests.adapters import BaseAdapter 8 | from requests.models import Response 9 | from requests.utils import get_encoding_from_headers 10 | from requests.cookies import extract_cookies_to_jar 11 | 12 | try: 13 | from http.client import responses 14 | except ImportError: 15 | from httplib import responses 16 | 17 | try: 18 | from urllib.parse import urlparse 19 | except ImportError: 20 | from urlparse import urlparse 21 | 22 | try: 23 | from urllib.parse import unquote 24 | except ImportError: 25 | from urllib2 import unquote as unquote2 26 | 27 | def unquote(s, encoding): 28 | return unquote2(s.decode(encoding)) 29 | 30 | 31 | try: 32 | timedelta_total_seconds = datetime.timedelta.total_seconds 33 | except AttributeError: 34 | 35 | def timedelta_total_seconds(timedelta): 36 | return ( 37 | timedelta.microseconds 38 | + 0.0 39 | + (timedelta.seconds + timedelta.days * 24 * 3600) * 10 ** 6 40 | ) / 10 ** 6 41 | 42 | 43 | logger = logging.getLogger(__name__) 44 | 45 | 46 | class Content(object): 47 | def __init__(self, content, length=None): 48 | self._read = 0 49 | if isinstance(content, bytes): 50 | self._bytes = io.BytesIO(content) 51 | self._len = len(content) 52 | else: 53 | self._bytes = content 54 | self._len = length 55 | 56 | def __len__(self): 57 | return self._len 58 | 59 | def read(self, amt=None): 60 | if amt: 61 | self._read += amt 62 | return self._bytes.read(amt) 63 | 64 | def readline(self): 65 | line = self._bytes.readline() 66 | self._read += len(line) 67 | return line 68 | 69 | def stream(self, amt=None, decode_content=None): 70 | while self._read < self._len: 71 | yield self.read(amt) 72 | 73 | def release_conn(self): 74 | pass 75 | 76 | def close(self): 77 | self._bytes.close() 78 | 79 | 80 | class MockObject(object): 81 | def __getattr__(self, name): 82 | setattr(self, name, MockObject()) 83 | return getattr(self, name) 84 | 85 | 86 | def make_headers(headers): 87 | if hasattr(headers, "items"): 88 | headers = headers.items() 89 | header_dict = HTTPHeaderDict() 90 | for key, value in headers: 91 | header_dict.add(key, value) 92 | return header_dict 93 | 94 | 95 | class WSGIAdapter(BaseAdapter): 96 | server_protocol = "HTTP/1.1" 97 | wsgi_version = (1, 0) 98 | 99 | def __init__( 100 | self, 101 | app, 102 | multiprocess=False, 103 | multithread=False, 104 | run_once=False, 105 | log_function=None, 106 | ): 107 | self.app = app 108 | self.multiprocess = multiprocess 109 | self.multithread = multithread 110 | self.run_once = run_once 111 | self._log = log_function or self._log 112 | self.errors = io.BytesIO() 113 | 114 | def send(self, request, *args, **kwargs): 115 | start = datetime.datetime.utcnow() 116 | 117 | urlinfo = urlparse(request.url) 118 | 119 | if not request.body: 120 | data = b"" 121 | elif isinstance(request.body, str): 122 | data = request.body.encode("utf-8") 123 | else: 124 | data = request.body 125 | 126 | if isinstance(data, bytes): 127 | length = len(data) 128 | else: 129 | length = int(request.headers.get("Content-Length")) 130 | 131 | environ = { 132 | "CONTENT_TYPE": request.headers.get("Content-Type", ""), 133 | "CONTENT_LENGTH": length, 134 | "PATH_INFO": unquote(urlinfo.path, encoding="latin-1"), 135 | "SCRIPT_NAME": "", 136 | "REQUEST_METHOD": request.method, 137 | "SERVER_NAME": urlinfo.hostname, 138 | "QUERY_STRING": urlinfo.query, 139 | "SERVER_PORT": str( 140 | urlinfo.port or (443 if urlinfo.scheme == "https" else 80) 141 | ), 142 | "SERVER_PROTOCOL": self.server_protocol, 143 | "wsgi.version": self.wsgi_version, 144 | "wsgi.input": Content(data, length), 145 | "wsgi.errors": self.errors, 146 | "wsgi.multiprocess": self.multiprocess, 147 | "wsgi.multithread": self.multithread, 148 | "wsgi.run_once": self.run_once, 149 | "wsgi.url_scheme": urlinfo.scheme, 150 | } 151 | 152 | environ.update( 153 | dict( 154 | ("HTTP_{0}".format(name).replace("-", "_").upper(), value) 155 | for name, value in request.headers.items() 156 | ) 157 | ) 158 | 159 | response = Response() 160 | resp = MockObject() 161 | 162 | def start_response(status, headers, exc_info=None): 163 | headers = make_headers(headers) 164 | response.status_code = int(status.split(" ")[0]) 165 | response.reason = responses.get(response.status_code, "Unknown Status Code") 166 | response.headers = headers 167 | resp._original_response.msg = headers 168 | extract_cookies_to_jar(response.cookies, request, resp) 169 | response.encoding = get_encoding_from_headers(response.headers) 170 | response.elapsed = datetime.datetime.utcnow() - start 171 | self._log(response) 172 | 173 | response.request = request 174 | response.url = request.url 175 | 176 | response.raw = Content(b"".join(self.app(environ, start_response))) 177 | response.raw._original_response = resp._original_response 178 | 179 | return response 180 | 181 | def close(self): 182 | pass 183 | 184 | def _log(self, response): 185 | if response.status_code < 400: 186 | log = logger.info 187 | elif response.status_code < 500: 188 | log = logger.warning 189 | else: 190 | log = logger.error 191 | 192 | summary = "{status} {method} {url} ({host}) {time}ms".format( 193 | status=response.status_code, 194 | method=response.request.method, 195 | url=response.request.path_url, 196 | host=urlparse(response.url).hostname, 197 | time=round(timedelta_total_seconds(response.elapsed) * 1000, 2), 198 | ) 199 | 200 | log(summary) 201 | --------------------------------------------------------------------------------