From ce0bfb52a68c24f25d29fd4aa1e1b6b07fda8bbf Mon Sep 17 00:00:00 2001 From: su-fang Date: Tue, 7 Feb 2023 14:29:03 +0800 Subject: [PATCH] Import Upstream version 0.10.1 --- AUTHORS.rst | 57 ++ CODE_OF_CONDUCT.rst | 54 ++ HISTORY.rst | 360 ++++++++++ LICENSE | 13 + MANIFEST.in | 14 + PKG-INFO | 496 +++++++++++++ README.rst | 108 +++ dev-requirements.txt | 5 + docs/Makefile | 177 +++++ docs/adapters.rst | 268 +++++++ docs/authentication.rst | 142 ++++ docs/conf.py | 271 ++++++++ docs/contributing.rst | 161 +++++ docs/deprecated.rst | 13 + docs/downloadutils.rst | 16 + docs/dumputils.rst | 17 + docs/exceptions.rst | 10 + docs/formdata.rst | 7 + docs/index.rst | 49 ++ docs/make.bat | 242 +++++++ docs/sessions.rst | 24 + docs/threading.rst | 170 +++++ docs/uploading-data.rst | 172 +++++ docs/user-agent.rst | 94 +++ docs/user.rst | 3 + requests_toolbelt.egg-info/PKG-INFO | 496 +++++++++++++ requests_toolbelt.egg-info/SOURCES.txt | 104 +++ .../dependency_links.txt | 1 + requests_toolbelt.egg-info/requires.txt | 1 + requests_toolbelt.egg-info/top_level.txt | 1 + requests_toolbelt/__init__.py | 34 + requests_toolbelt/_compat.py | 311 +++++++++ requests_toolbelt/adapters/__init__.py | 15 + requests_toolbelt/adapters/appengine.py | 206 ++++++ requests_toolbelt/adapters/fingerprint.py | 48 ++ requests_toolbelt/adapters/host_header_ssl.py | 43 ++ requests_toolbelt/adapters/socket_options.py | 129 ++++ requests_toolbelt/adapters/source.py | 67 ++ requests_toolbelt/adapters/ssl.py | 66 ++ requests_toolbelt/adapters/x509.py | 196 ++++++ requests_toolbelt/auth/__init__.py | 0 requests_toolbelt/auth/_digest_auth_compat.py | 29 + requests_toolbelt/auth/guess.py | 146 ++++ requests_toolbelt/auth/handler.py | 142 ++++ requests_toolbelt/auth/http_proxy_digest.py | 103 +++ requests_toolbelt/cookies/__init__.py | 0 requests_toolbelt/cookies/forgetful.py | 7 + requests_toolbelt/downloadutils/__init__.py | 0 requests_toolbelt/downloadutils/stream.py | 176 +++++ requests_toolbelt/downloadutils/tee.py | 123 ++++ requests_toolbelt/exceptions.py | 37 + requests_toolbelt/multipart/__init__.py | 31 + requests_toolbelt/multipart/decoder.py | 156 +++++ requests_toolbelt/multipart/encoder.py | 655 ++++++++++++++++++ requests_toolbelt/sessions.py | 89 +++ requests_toolbelt/streaming_iterator.py | 116 ++++ requests_toolbelt/threaded/__init__.py | 97 +++ requests_toolbelt/threaded/pool.py | 211 ++++++ requests_toolbelt/threaded/thread.py | 53 ++ requests_toolbelt/utils/__init__.py | 0 requests_toolbelt/utils/deprecated.py | 91 +++ requests_toolbelt/utils/dump.py | 198 ++++++ requests_toolbelt/utils/formdata.py | 108 +++ requests_toolbelt/utils/user_agent.py | 143 ++++ setup.cfg | 7 + setup.py | 73 ++ tests/__init__.py | 8 + tests/cassettes/file_for_download.json | 1 + tests/cassettes/http2bin_cookies.json | 1 + tests/cassettes/http2bin_fingerprint.json | 1 + tests/cassettes/httpbin_guess_auth_basic.json | 1 + .../cassettes/httpbin_guess_auth_digest.json | 1 + tests/cassettes/httpbin_guess_auth_none.json | 1 + tests/cassettes/klevas_vu_lt_ssl3.json | 1 + .../redirect_request_for_dump_all.json | 1 + tests/cassettes/simple_get_request.json | 1 + tests/cassettes/stream_response_to_file.json | 1 + tests/cassettes/test_x509_adapter_der.json | 1 + tests/cassettes/test_x509_adapter_pem.json | 1 + tests/conftest.py | 15 + tests/test_appengine_adapter.py | 92 +++ tests/test_auth.py | 80 +++ tests/test_auth_handler.py | 58 ++ tests/test_downloadutils.py | 223 ++++++ tests/test_dump.py | 405 +++++++++++ tests/test_fingerprintadapter.py | 20 + tests/test_forgetfulcookiejar.py | 26 + tests/test_formdata.py | 76 ++ tests/test_host_header_ssl_adapter.py | 48 ++ tests/test_multipart_decoder.py | 193 ++++++ tests/test_multipart_encoder.py | 326 +++++++++ tests/test_multipart_monitor.py | 65 ++ tests/test_proxy_digest_auth.py | 115 +++ tests/test_sessions.py | 55 ++ tests/test_socket_options_adapter.py | 129 ++++ tests/test_source_adapter.py | 41 ++ tests/test_ssladapter.py | 34 + tests/test_streaming_iterator.py | 68 ++ tests/test_user_agent.py | 108 +++ tests/test_x509_adapter.py | 83 +++ tests/threaded/__init__.py | 0 tests/threaded/test_api.py | 62 ++ tests/threaded/test_pool.py | 234 +++++++ tests/threaded/test_thread.py | 137 ++++ tox.ini | 80 +++ 105 files changed, 10245 insertions(+) create mode 100644 AUTHORS.rst create mode 100644 CODE_OF_CONDUCT.rst create mode 100644 HISTORY.rst create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 PKG-INFO create mode 100644 README.rst create mode 100644 dev-requirements.txt create mode 100644 docs/Makefile create mode 100644 docs/adapters.rst create mode 100644 docs/authentication.rst create mode 100644 docs/conf.py create mode 100644 docs/contributing.rst create mode 100644 docs/deprecated.rst create mode 100644 docs/downloadutils.rst create mode 100644 docs/dumputils.rst create mode 100644 docs/exceptions.rst create mode 100644 docs/formdata.rst create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/sessions.rst create mode 100644 docs/threading.rst create mode 100644 docs/uploading-data.rst create mode 100644 docs/user-agent.rst create mode 100644 docs/user.rst create mode 100644 requests_toolbelt.egg-info/PKG-INFO create mode 100644 requests_toolbelt.egg-info/SOURCES.txt create mode 100644 requests_toolbelt.egg-info/dependency_links.txt create mode 100644 requests_toolbelt.egg-info/requires.txt create mode 100644 requests_toolbelt.egg-info/top_level.txt create mode 100644 requests_toolbelt/__init__.py create mode 100644 requests_toolbelt/_compat.py create mode 100644 requests_toolbelt/adapters/__init__.py create mode 100644 requests_toolbelt/adapters/appengine.py create mode 100644 requests_toolbelt/adapters/fingerprint.py create mode 100644 requests_toolbelt/adapters/host_header_ssl.py create mode 100644 requests_toolbelt/adapters/socket_options.py create mode 100644 requests_toolbelt/adapters/source.py create mode 100644 requests_toolbelt/adapters/ssl.py create mode 100644 requests_toolbelt/adapters/x509.py create mode 100644 requests_toolbelt/auth/__init__.py create mode 100644 requests_toolbelt/auth/_digest_auth_compat.py create mode 100644 requests_toolbelt/auth/guess.py create mode 100644 requests_toolbelt/auth/handler.py create mode 100644 requests_toolbelt/auth/http_proxy_digest.py create mode 100644 requests_toolbelt/cookies/__init__.py create mode 100644 requests_toolbelt/cookies/forgetful.py create mode 100644 requests_toolbelt/downloadutils/__init__.py create mode 100644 requests_toolbelt/downloadutils/stream.py create mode 100644 requests_toolbelt/downloadutils/tee.py create mode 100644 requests_toolbelt/exceptions.py create mode 100644 requests_toolbelt/multipart/__init__.py create mode 100644 requests_toolbelt/multipart/decoder.py create mode 100644 requests_toolbelt/multipart/encoder.py create mode 100644 requests_toolbelt/sessions.py create mode 100644 requests_toolbelt/streaming_iterator.py create mode 100644 requests_toolbelt/threaded/__init__.py create mode 100644 requests_toolbelt/threaded/pool.py create mode 100644 requests_toolbelt/threaded/thread.py create mode 100644 requests_toolbelt/utils/__init__.py create mode 100644 requests_toolbelt/utils/deprecated.py create mode 100644 requests_toolbelt/utils/dump.py create mode 100644 requests_toolbelt/utils/formdata.py create mode 100644 requests_toolbelt/utils/user_agent.py create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/cassettes/file_for_download.json create mode 100644 tests/cassettes/http2bin_cookies.json create mode 100644 tests/cassettes/http2bin_fingerprint.json create mode 100644 tests/cassettes/httpbin_guess_auth_basic.json create mode 100644 tests/cassettes/httpbin_guess_auth_digest.json create mode 100644 tests/cassettes/httpbin_guess_auth_none.json create mode 100644 tests/cassettes/klevas_vu_lt_ssl3.json create mode 100644 tests/cassettes/redirect_request_for_dump_all.json create mode 100644 tests/cassettes/simple_get_request.json create mode 100644 tests/cassettes/stream_response_to_file.json create mode 100644 tests/cassettes/test_x509_adapter_der.json create mode 100644 tests/cassettes/test_x509_adapter_pem.json create mode 100644 tests/conftest.py create mode 100644 tests/test_appengine_adapter.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_auth_handler.py create mode 100644 tests/test_downloadutils.py create mode 100644 tests/test_dump.py create mode 100644 tests/test_fingerprintadapter.py create mode 100644 tests/test_forgetfulcookiejar.py create mode 100644 tests/test_formdata.py create mode 100644 tests/test_host_header_ssl_adapter.py create mode 100644 tests/test_multipart_decoder.py create mode 100644 tests/test_multipart_encoder.py create mode 100644 tests/test_multipart_monitor.py create mode 100644 tests/test_proxy_digest_auth.py create mode 100644 tests/test_sessions.py create mode 100644 tests/test_socket_options_adapter.py create mode 100644 tests/test_source_adapter.py create mode 100644 tests/test_ssladapter.py create mode 100644 tests/test_streaming_iterator.py create mode 100644 tests/test_user_agent.py create mode 100644 tests/test_x509_adapter.py create mode 100644 tests/threaded/__init__.py create mode 100644 tests/threaded/test_api.py create mode 100644 tests/threaded/test_pool.py create mode 100644 tests/threaded/test_thread.py create mode 100644 tox.ini diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 0000000..1f036a4 --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,57 @@ +Requests-toolbelt is written and maintained by Ian Cordasco, Cory Benfield and +various contributors: + +Development Lead +```````````````` + +- Ian Cordasco + +- Cory Benfield + + +Requests +```````` + +- Kenneth Reitz and various contributors + + +Urllib3 +``````` + +- Andrey Petrov + + +Patches and Suggestions +``````````````````````` + +- Jay De Lanoy + +- Zhaoyu Luo + +- Markus Unterwaditzer + +- Bryce Boe (@bboe) + +- Dan Lipsitt (https://github.com/DanLipsitt) + +- Cea Stapleton (http://www.ceastapleton.com) + +- Patrick Creech + +- Mike Lambert (@mikelambert) + +- Ryan Barrett (https://snarfed.org/) + +- Victor Grau Serrat (@lacabra) + +- Yorgos Pagles + +- Thomas Hauk + +- Achim Herwig + +- Ryan Ashley + +- Sam Bull (@greatestape) + +- Florence Blanc-Renaud (@flo-renaud) diff --git a/CODE_OF_CONDUCT.rst b/CODE_OF_CONDUCT.rst new file mode 100644 index 0000000..d9e4742 --- /dev/null +++ b/CODE_OF_CONDUCT.rst @@ -0,0 +1,54 @@ +Contributor Code of Conduct +--------------------------- + +As contributors and maintainers of this project, and in the interest of +fostering an open and welcoming community, we pledge to respect all +people who contribute through reporting issues, posting feature +requests, updating documentation, submitting pull requests or patches, +and other activities. + +We are committed to making participation in this project a +harassment-free experience for everyone, regardless of level of +experience, gender, gender identity and expression, sexual orientation, +disability, personal appearance, body size, race, ethnicity, age, +religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, such as physical or electronic + addresses, without explicit permission +* Other unethical or unprofessional conduct + +Project maintainers have the right and responsibility to remove, edit, +or reject comments, commits, code, wiki edits, issues, and other +contributions that are not aligned to this Code of Conduct, or to ban +temporarily or permanently any contributor for other behaviors that they +deem inappropriate, threatening, offensive, or harmful. + +By adopting this Code of Conduct, project maintainers commit themselves +to fairly and consistently applying these principles to every aspect of +managing this project. Project maintainers who do not follow or enforce +the Code of Conduct may be permanently removed from the project team. + +This code of conduct applies both within project spaces and in public +spaces when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may +be reported by contacting a project maintainer at graffatcolmingov@gmail.com. +All complaints will be reviewed and investigated and will +result in a response that is deemed necessary and appropriate to the +circumstances. Maintainers are obligated to maintain confidentiality +with regard to the reporter of an incident. + +This Code of Conduct is adapted from the `Contributor Covenant`_, version +1.3.0, available at https://www.contributor-covenant.org/version/1/3/0/ + +.. _Contributor Covenant: https://www.contributor-covenant.org/ + +.. + Re-formatted to reStructuredText from + https://raw.githubusercontent.com/CoralineAda/contributor_covenant/master/CODE_OF_CONDUCT.md diff --git a/HISTORY.rst b/HISTORY.rst new file mode 100644 index 0000000..0045de0 --- /dev/null +++ b/HISTORY.rst @@ -0,0 +1,360 @@ +History +======= + +0.10.1 -- 2022-10-25 +-------------------- + +Fixed Bugs +~~~~~~~~~~ + +- Fix urllib3 warning to only emit on X509Adapter usage + +0.10.0 -- 2022-10-06 +-------------------- + +New Features +~~~~~~~~~~~~ + +- Add support for preparing requests in BaseUrlSession + +Fixed Bugs +~~~~~~~~~~ + +- Fixing missing newline in dump utility + +0.9.1 -- 2019-01-29 +------------------- + +Fixed Bugs +~~~~~~~~~~ + +- Fix import of pyOpenSSL shim from urllib3 for PKCS12 adapter + +0.9.0 -- 2019-01-29 +------------------- + +New Features +~~~~~~~~~~~~ + +- Add X509 Adapter that can handle PKCS12 +- Add stateless solution for streaming files by MultipartEncoder from one host to another (in chunks) + +Fixed Bugs +~~~~~~~~~~ + +- Update link to example +- Move import of ``ABCs`` from collections into version-specific part of + _compat module +- Fix backwards incompatibility in ``get_encodings_from_content`` +- Correct callback documentation for ``MultipartEncoderMonitor`` +- Fix bug when ``MultipartEncoder`` is asked to encode zero parts +- Correct the type of non string request body dumps +- Removed content from being stored in MultipartDecoder +- Fix bug by enabling support for contenttype with capital letters. +- Coerce proxy URL to bytes before dumping request +- Avoid bailing out with exception upon empty response reason +- Corrected Pool documentation +- Corrected parentheses match in example usage +- Fix "oject" to "object" in ``MultipartEncoder`` +- Fix URL for the project after the move +- Add fix for OSX TCPKeepAliveAdapter + +Miscellaneous +~~~~~~~~~~~~~ + +- Remove py33 from testing and add Python 3.6 and nightly testing to the travis matrix. + +0.8.0 -- 2017-05-20 +------------------- + +More information about this release can be found on the `0.8.0 milestone`_. + +New Features +~~~~~~~~~~~~ + +- Add ``UserAgentBuilder`` to provide more control over generated User-Agent + strings. + +Fixed Bugs +~~~~~~~~~~ + +- Include ``_validate_certificate`` in the lits of picked attributes on the + ``AppEngineAdapter``. +- Fix backwards incompatibility in ``get_encodings_from_content`` + +.. _0.8.0 milestone: + https://github.com/requests/toolbelt/milestones/0.8.0 + +0.7.1 -- 2017-02-13 +------------------- + +More information about this release can be found on the `0.7.1 milestone`_. + +Fixed Bugs +~~~~~~~~~~ + +- Fixed monkey-patching for the AppEngineAdapter. + +- Make it easier to disable certificate verification when monkey-patching + AppEngine. + +- Handle ``multipart/form-data`` bodies without a trailing ``CRLF``. + + +.. links +.. _0.7.1 milestone: + https://github.com/requests/toolbelt/milestone/9 + +0.7.0 -- 2016-07-21 +------------------- + +More information about this release can be found on the `0.7.0 milestone`_. + +New Features +~~~~~~~~~~~~ + +- Add ``BaseUrlSession`` to allow developers to have a session that has a + "Base" URL. See the documentation for more details and examples. + +- Split the logic of ``stream_response_to_file`` into two separate functions: + + * ``get_download_file_path`` to generate the file name from the Response. + + * ``stream_response_to_file`` which will use ``get_download_file_path`` if + necessary + +Fixed Bugs +~~~~~~~~~~ + +- Fixed the issue for people using *very* old versions of Requests where they + would see an ImportError from ``requests_toolbelt._compat`` when trying to + import ``connection``. + + +.. _0.7.0 milestone: + https://github.com/requests/toolbelt/milestones/0.7.0 + +0.6.2 -- 2016-05-10 +------------------- + +Fixed Bugs +~~~~~~~~~~ + +- When passing a timeout via Requests, it was not appropriately translated to + the timeout that the urllib3 code was expecting. + +0.6.1 -- 2016-05-05 +------------------- + +Fixed Bugs +~~~~~~~~~~ + +- Remove assertion about request URLs in the AppEngineAdapter. + +- Prevent pip from installing requests 3.0.0 when that is released until we + are ready to handle it. + +0.6.0 -- 2016-01-27 +------------------- + +More information about this release can be found on the `0.6.0 milestone`_. + +New Features +~~~~~~~~~~~~ + +- Add ``AppEngineAdapter`` to support developers using Google's AppEngine + platform with Requests. + +- Add ``GuessProxyAuth`` class to support guessing between Basic and Digest + Authentication for proxies. + +Fixed Bugs +~~~~~~~~~~ + +- Ensure that proxies use the correct TLS version when using the + ``SSLAdapter``. + +- Fix an ``AttributeError`` when using the ``HTTPProxyDigestAuth`` class. + +Miscellaneous +~~~~~~~~~~~~~ + +- Drop testing support for Python 3.2. virtualenv and pip have stopped + supporting it meaning that it is harder to test for this with our CI + infrastructure. Moving forward we will make a best-effort attempt to + support 3.2 but will not test for it. + + +.. _0.6.0 milestone: + https://github.com/requests/toolbelt/milestones/0.6.0 + +0.5.1 -- 2015-12-16 +------------------- + +More information about this release can be found on the `0.5.1 milestone`_. + +Fixed Bugs +~~~~~~~~~~ + +- Now papers over the differences in requests' ``super_len`` function from + versions prior to 2.9.0 and versions 2.9.0 and later. + + +.. _0.5.1 milestone: + https://github.com/requests/toolbelt/milestones/0.5.1 + +0.5.0 -- 2015-11-24 +------------------- + +More information about this release can be found on the `milestone +`_ +for 0.5.0. + +New Features +~~~~~~~~~~~~ + +- The ``tee`` submodule was added to ``requests_toolbelt.downloadutils``. It + allows you to iterate over the bytes of a response while also writing them + to a file. The ``tee.tee`` function, expects you to pass an open file + object, while ``tee.tee_to_file`` will use the provided file name to open + the file for you. + +- Added a new parameter to ``requests_toolbelt.utils.user_agent`` that allows + the user to specify additional items. + +- Added nested form-data helper, + ``requests_toolbelt.utils.formdata.urlencode``. + +- Added the ``ForgetfulCookieJar`` to ``requests_toolbelt.cookies``. + +- Added utilities for dumping the information about a request-response cycle + in ``requests_toolbelt.utils.dump``. + +- Implemented the API described in the ``requests_toolbelt.threaded`` module + docstring, i.e., added ``requests_toolbelt.threaded.map`` as an available + function. + +Fixed Bugs +~~~~~~~~~~ + +- Now papers over the API differences in versions of requests installed from + system packages versus versions of requests installed from PyPI. + +- Allow string types for ``SourceAddressAdapter``. + +0.4.0 -- 2015-04-03 +------------------- + +For more information about this release, please see `milestone 0.4.0 +`_ +on the project's page. + +New Features +~~~~~~~~~~~~ + +- A naive implemenation of a thread pool is now included in the toolbelt. See + the docs in ``docs/threading.rst`` or on `Read The Docs + `_. + +- The ``StreamingIterator`` now accepts files (such as ``sys.stdin``) without + a specific length and will properly stream them. + +- The ``MultipartEncoder`` now accepts exactly the same format of fields as + requests' ``files`` parameter does. In other words, you can now also pass in + extra headers to add to a part in the body. You can also now specify a + custom ``Content-Type`` for a part. + +- An implementation of HTTP Digest Authentication for Proxies is now included. + +- A transport adapter that allows a user to specify a specific Certificate + Fingerprint is now included in the toolbelt. + +- A transport adapter that simplifies how users specify socket options is now + included. + +- A transport adapter that simplifies how users can specify TCP Keep-Alive + options is now included in the toolbelt. + +- Deprecated functions from ``requests.utils`` are now included and + maintained. + +- An authentication tool that allows users to specify how to authenticate to + several different domains at once is now included. + +- A function to save streamed responses to disk by analyzing the + ``Content-Disposition`` header is now included in the toolbelt. + +Fixed Bugs +~~~~~~~~~~ + +- The ``MultipartEncoder`` will now allow users to upload files larger than + 4GB on 32-bit systems. + +- The ``MultipartEncoder`` will now accept empty unicode strings for form + values. + +0.3.1 -- 2014-06-23 +------------------- + +- Fix the fact that 0.3.0 bundle did not include the ``StreamingIterator`` + +0.3.0 -- 2014-05-21 +------------------- + +Bug Fixes +~~~~~~~~~ + +- Complete rewrite of ``MultipartEncoder`` fixes bug where bytes were lost in + uploads + +New Features +~~~~~~~~~~~~ + +- ``MultipartDecoder`` to accept ``multipart/form-data`` response bodies and + parse them into an easy to use object. + +- ``SourceAddressAdapter`` to allow users to choose a local address to bind + connections to. + +- ``GuessAuth`` which accepts a username and password and uses the + ``WWW-Authenticate`` header to determine how to authenticate against a + server. + +- ``MultipartEncoderMonitor`` wraps an instance of the ``MultipartEncoder`` + and keeps track of how many bytes were read and will call the provided + callback. + +- ``StreamingIterator`` will wrap an iterator and stream the upload instead of + chunk it, provided you also provide the length of the content you wish to + upload. + +0.2.0 -- 2014-02-24 +------------------- + +- Add ability to tell ``MultipartEncoder`` which encoding to use. By default + it uses 'utf-8'. + +- Fix #10 - allow users to install with pip + +- Fix #9 - Fix ``MultipartEncoder#to_string`` so that it properly handles file + objects as fields + +0.1.2 -- 2014-01-19 +------------------- + +- At some point during development we broke how we handle normal file objects. + Thanks to @konomae this is now fixed. + +0.1.1 -- 2014-01-19 +------------------- + +- Handle ``io.BytesIO``-like objects better + +0.1.0 -- 2014-01-18 +------------------- + +- Add initial implementation of the streaming ``MultipartEncoder`` + +- Add initial implementation of the ``user_agent`` function + +- Add the ``SSLAdapter`` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0bc71d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2014 Ian Cordasco, Cory Benfield + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..bb6d32d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,14 @@ +include README.rst +include LICENSE +include HISTORY.rst +include AUTHORS.rst +include CODE_OF_CONDUCT.rst +include tox.ini +include dev-requirements.txt + +recursive-include requests_toolbelt * +recursive-include docs * +recursive-include tests * + +prune docs/_build +global-exclude *.py[cdo] __pycache__ *.so *.pyd diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..228940d --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,496 @@ +Metadata-Version: 2.1 +Name: requests-toolbelt +Version: 0.10.1 +Summary: A utility belt for advanced users of python-requests +Home-page: https://toolbelt.readthedocs.io/ +Author: Ian Cordasco, Cory Benfield +Author-email: graffatcolmingov@gmail.com +License: Apache 2.0 +Classifier: Development Status :: 5 - Production/Stable +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* +Description-Content-Type: text/x-rst +License-File: LICENSE +License-File: AUTHORS.rst + +The Requests Toolbelt +===================== + +This is just a collection of utilities for `python-requests`_, but don't +really belong in ``requests`` proper. The minimum tested requests version is +``2.1.0``. In reality, the toolbelt should work with ``2.0.1`` as well, but +some idiosyncracies prevent effective or sane testing on that version. + +``pip install requests-toolbelt`` to get started! + + +multipart/form-data Encoder +--------------------------- + +The main attraction is a streaming multipart form-data object, ``MultipartEncoder``. +Its API looks like this: + +.. code-block:: python + + from requests_toolbelt import MultipartEncoder + import requests + + m = MultipartEncoder( + fields={'field0': 'value', 'field1': 'value', + 'field2': ('filename', open('file.py', 'rb'), 'text/plain')} + ) + + r = requests.post('http://httpbin.org/post', data=m, + headers={'Content-Type': m.content_type}) + + +You can also use ``multipart/form-data`` encoding for requests that don't +require files: + +.. code-block:: python + + from requests_toolbelt import MultipartEncoder + import requests + + m = MultipartEncoder(fields={'field0': 'value', 'field1': 'value'}) + + r = requests.post('http://httpbin.org/post', data=m, + headers={'Content-Type': m.content_type}) + + +Or, you can just create the string and examine the data: + +.. code-block:: python + + # Assuming `m` is one of the above + m.to_string() # Always returns unicode + + +User-Agent constructor +---------------------- + +You can easily construct a requests-style ``User-Agent`` string:: + + from requests_toolbelt import user_agent + + headers = { + 'User-Agent': user_agent('my_package', '0.0.1') + } + + r = requests.get('https://api.github.com/users', headers=headers) + + +SSLAdapter +---------- + +The ``SSLAdapter`` was originally published on `Cory Benfield's blog`_. +This adapter allows the user to choose one of the SSL protocols made available +in Python's ``ssl`` module for outgoing HTTPS connections: + +.. code-block:: python + + from requests_toolbelt import SSLAdapter + import requests + import ssl + + s = requests.Session() + s.mount('https://', SSLAdapter(ssl.PROTOCOL_TLSv1)) + +cookies/ForgetfulCookieJar +-------------------------- + +The ``ForgetfulCookieJar`` prevents a particular requests session from storing +cookies: + +.. code-block:: python + + from requests_toolbelt.cookies.forgetful import ForgetfulCookieJar + + session = requests.Session() + session.cookies = ForgetfulCookieJar() + +Contributing +------------ + +Please read the `suggested workflow +`_ for +contributing to this project. + +Please report any bugs on the `issue tracker`_ + +.. _Cory Benfield's blog: https://lukasa.co.uk/2013/01/Choosing_SSL_Version_In_Requests/ +.. _python-requests: https://github.com/kennethreitz/requests +.. _issue tracker: https://github.com/requests/toolbelt/issues + + +History +======= + +0.10.1 -- 2022-10-25 +-------------------- + +Fixed Bugs +~~~~~~~~~~ + +- Fix urllib3 warning to only emit on X509Adapter usage + +0.10.0 -- 2022-10-06 +-------------------- + +New Features +~~~~~~~~~~~~ + +- Add support for preparing requests in BaseUrlSession + +Fixed Bugs +~~~~~~~~~~ + +- Fixing missing newline in dump utility + +0.9.1 -- 2019-01-29 +------------------- + +Fixed Bugs +~~~~~~~~~~ + +- Fix import of pyOpenSSL shim from urllib3 for PKCS12 adapter + +0.9.0 -- 2019-01-29 +------------------- + +New Features +~~~~~~~~~~~~ + +- Add X509 Adapter that can handle PKCS12 +- Add stateless solution for streaming files by MultipartEncoder from one host to another (in chunks) + +Fixed Bugs +~~~~~~~~~~ + +- Update link to example +- Move import of ``ABCs`` from collections into version-specific part of + _compat module +- Fix backwards incompatibility in ``get_encodings_from_content`` +- Correct callback documentation for ``MultipartEncoderMonitor`` +- Fix bug when ``MultipartEncoder`` is asked to encode zero parts +- Correct the type of non string request body dumps +- Removed content from being stored in MultipartDecoder +- Fix bug by enabling support for contenttype with capital letters. +- Coerce proxy URL to bytes before dumping request +- Avoid bailing out with exception upon empty response reason +- Corrected Pool documentation +- Corrected parentheses match in example usage +- Fix "oject" to "object" in ``MultipartEncoder`` +- Fix URL for the project after the move +- Add fix for OSX TCPKeepAliveAdapter + +Miscellaneous +~~~~~~~~~~~~~ + +- Remove py33 from testing and add Python 3.6 and nightly testing to the travis matrix. + +0.8.0 -- 2017-05-20 +------------------- + +More information about this release can be found on the `0.8.0 milestone`_. + +New Features +~~~~~~~~~~~~ + +- Add ``UserAgentBuilder`` to provide more control over generated User-Agent + strings. + +Fixed Bugs +~~~~~~~~~~ + +- Include ``_validate_certificate`` in the lits of picked attributes on the + ``AppEngineAdapter``. +- Fix backwards incompatibility in ``get_encodings_from_content`` + +.. _0.8.0 milestone: + https://github.com/requests/toolbelt/milestones/0.8.0 + +0.7.1 -- 2017-02-13 +------------------- + +More information about this release can be found on the `0.7.1 milestone`_. + +Fixed Bugs +~~~~~~~~~~ + +- Fixed monkey-patching for the AppEngineAdapter. + +- Make it easier to disable certificate verification when monkey-patching + AppEngine. + +- Handle ``multipart/form-data`` bodies without a trailing ``CRLF``. + + +.. links +.. _0.7.1 milestone: + https://github.com/requests/toolbelt/milestone/9 + +0.7.0 -- 2016-07-21 +------------------- + +More information about this release can be found on the `0.7.0 milestone`_. + +New Features +~~~~~~~~~~~~ + +- Add ``BaseUrlSession`` to allow developers to have a session that has a + "Base" URL. See the documentation for more details and examples. + +- Split the logic of ``stream_response_to_file`` into two separate functions: + + * ``get_download_file_path`` to generate the file name from the Response. + + * ``stream_response_to_file`` which will use ``get_download_file_path`` if + necessary + +Fixed Bugs +~~~~~~~~~~ + +- Fixed the issue for people using *very* old versions of Requests where they + would see an ImportError from ``requests_toolbelt._compat`` when trying to + import ``connection``. + + +.. _0.7.0 milestone: + https://github.com/requests/toolbelt/milestones/0.7.0 + +0.6.2 -- 2016-05-10 +------------------- + +Fixed Bugs +~~~~~~~~~~ + +- When passing a timeout via Requests, it was not appropriately translated to + the timeout that the urllib3 code was expecting. + +0.6.1 -- 2016-05-05 +------------------- + +Fixed Bugs +~~~~~~~~~~ + +- Remove assertion about request URLs in the AppEngineAdapter. + +- Prevent pip from installing requests 3.0.0 when that is released until we + are ready to handle it. + +0.6.0 -- 2016-01-27 +------------------- + +More information about this release can be found on the `0.6.0 milestone`_. + +New Features +~~~~~~~~~~~~ + +- Add ``AppEngineAdapter`` to support developers using Google's AppEngine + platform with Requests. + +- Add ``GuessProxyAuth`` class to support guessing between Basic and Digest + Authentication for proxies. + +Fixed Bugs +~~~~~~~~~~ + +- Ensure that proxies use the correct TLS version when using the + ``SSLAdapter``. + +- Fix an ``AttributeError`` when using the ``HTTPProxyDigestAuth`` class. + +Miscellaneous +~~~~~~~~~~~~~ + +- Drop testing support for Python 3.2. virtualenv and pip have stopped + supporting it meaning that it is harder to test for this with our CI + infrastructure. Moving forward we will make a best-effort attempt to + support 3.2 but will not test for it. + + +.. _0.6.0 milestone: + https://github.com/requests/toolbelt/milestones/0.6.0 + +0.5.1 -- 2015-12-16 +------------------- + +More information about this release can be found on the `0.5.1 milestone`_. + +Fixed Bugs +~~~~~~~~~~ + +- Now papers over the differences in requests' ``super_len`` function from + versions prior to 2.9.0 and versions 2.9.0 and later. + + +.. _0.5.1 milestone: + https://github.com/requests/toolbelt/milestones/0.5.1 + +0.5.0 -- 2015-11-24 +------------------- + +More information about this release can be found on the `milestone +`_ +for 0.5.0. + +New Features +~~~~~~~~~~~~ + +- The ``tee`` submodule was added to ``requests_toolbelt.downloadutils``. It + allows you to iterate over the bytes of a response while also writing them + to a file. The ``tee.tee`` function, expects you to pass an open file + object, while ``tee.tee_to_file`` will use the provided file name to open + the file for you. + +- Added a new parameter to ``requests_toolbelt.utils.user_agent`` that allows + the user to specify additional items. + +- Added nested form-data helper, + ``requests_toolbelt.utils.formdata.urlencode``. + +- Added the ``ForgetfulCookieJar`` to ``requests_toolbelt.cookies``. + +- Added utilities for dumping the information about a request-response cycle + in ``requests_toolbelt.utils.dump``. + +- Implemented the API described in the ``requests_toolbelt.threaded`` module + docstring, i.e., added ``requests_toolbelt.threaded.map`` as an available + function. + +Fixed Bugs +~~~~~~~~~~ + +- Now papers over the API differences in versions of requests installed from + system packages versus versions of requests installed from PyPI. + +- Allow string types for ``SourceAddressAdapter``. + +0.4.0 -- 2015-04-03 +------------------- + +For more information about this release, please see `milestone 0.4.0 +`_ +on the project's page. + +New Features +~~~~~~~~~~~~ + +- A naive implemenation of a thread pool is now included in the toolbelt. See + the docs in ``docs/threading.rst`` or on `Read The Docs + `_. + +- The ``StreamingIterator`` now accepts files (such as ``sys.stdin``) without + a specific length and will properly stream them. + +- The ``MultipartEncoder`` now accepts exactly the same format of fields as + requests' ``files`` parameter does. In other words, you can now also pass in + extra headers to add to a part in the body. You can also now specify a + custom ``Content-Type`` for a part. + +- An implementation of HTTP Digest Authentication for Proxies is now included. + +- A transport adapter that allows a user to specify a specific Certificate + Fingerprint is now included in the toolbelt. + +- A transport adapter that simplifies how users specify socket options is now + included. + +- A transport adapter that simplifies how users can specify TCP Keep-Alive + options is now included in the toolbelt. + +- Deprecated functions from ``requests.utils`` are now included and + maintained. + +- An authentication tool that allows users to specify how to authenticate to + several different domains at once is now included. + +- A function to save streamed responses to disk by analyzing the + ``Content-Disposition`` header is now included in the toolbelt. + +Fixed Bugs +~~~~~~~~~~ + +- The ``MultipartEncoder`` will now allow users to upload files larger than + 4GB on 32-bit systems. + +- The ``MultipartEncoder`` will now accept empty unicode strings for form + values. + +0.3.1 -- 2014-06-23 +------------------- + +- Fix the fact that 0.3.0 bundle did not include the ``StreamingIterator`` + +0.3.0 -- 2014-05-21 +------------------- + +Bug Fixes +~~~~~~~~~ + +- Complete rewrite of ``MultipartEncoder`` fixes bug where bytes were lost in + uploads + +New Features +~~~~~~~~~~~~ + +- ``MultipartDecoder`` to accept ``multipart/form-data`` response bodies and + parse them into an easy to use object. + +- ``SourceAddressAdapter`` to allow users to choose a local address to bind + connections to. + +- ``GuessAuth`` which accepts a username and password and uses the + ``WWW-Authenticate`` header to determine how to authenticate against a + server. + +- ``MultipartEncoderMonitor`` wraps an instance of the ``MultipartEncoder`` + and keeps track of how many bytes were read and will call the provided + callback. + +- ``StreamingIterator`` will wrap an iterator and stream the upload instead of + chunk it, provided you also provide the length of the content you wish to + upload. + +0.2.0 -- 2014-02-24 +------------------- + +- Add ability to tell ``MultipartEncoder`` which encoding to use. By default + it uses 'utf-8'. + +- Fix #10 - allow users to install with pip + +- Fix #9 - Fix ``MultipartEncoder#to_string`` so that it properly handles file + objects as fields + +0.1.2 -- 2014-01-19 +------------------- + +- At some point during development we broke how we handle normal file objects. + Thanks to @konomae this is now fixed. + +0.1.1 -- 2014-01-19 +------------------- + +- Handle ``io.BytesIO``-like objects better + +0.1.0 -- 2014-01-18 +------------------- + +- Add initial implementation of the streaming ``MultipartEncoder`` + +- Add initial implementation of the ``user_agent`` function + +- Add the ``SSLAdapter`` diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..9969c68 --- /dev/null +++ b/README.rst @@ -0,0 +1,108 @@ +The Requests Toolbelt +===================== + +This is just a collection of utilities for `python-requests`_, but don't +really belong in ``requests`` proper. The minimum tested requests version is +``2.1.0``. In reality, the toolbelt should work with ``2.0.1`` as well, but +some idiosyncracies prevent effective or sane testing on that version. + +``pip install requests-toolbelt`` to get started! + + +multipart/form-data Encoder +--------------------------- + +The main attraction is a streaming multipart form-data object, ``MultipartEncoder``. +Its API looks like this: + +.. code-block:: python + + from requests_toolbelt import MultipartEncoder + import requests + + m = MultipartEncoder( + fields={'field0': 'value', 'field1': 'value', + 'field2': ('filename', open('file.py', 'rb'), 'text/plain')} + ) + + r = requests.post('http://httpbin.org/post', data=m, + headers={'Content-Type': m.content_type}) + + +You can also use ``multipart/form-data`` encoding for requests that don't +require files: + +.. code-block:: python + + from requests_toolbelt import MultipartEncoder + import requests + + m = MultipartEncoder(fields={'field0': 'value', 'field1': 'value'}) + + r = requests.post('http://httpbin.org/post', data=m, + headers={'Content-Type': m.content_type}) + + +Or, you can just create the string and examine the data: + +.. code-block:: python + + # Assuming `m` is one of the above + m.to_string() # Always returns unicode + + +User-Agent constructor +---------------------- + +You can easily construct a requests-style ``User-Agent`` string:: + + from requests_toolbelt import user_agent + + headers = { + 'User-Agent': user_agent('my_package', '0.0.1') + } + + r = requests.get('https://api.github.com/users', headers=headers) + + +SSLAdapter +---------- + +The ``SSLAdapter`` was originally published on `Cory Benfield's blog`_. +This adapter allows the user to choose one of the SSL protocols made available +in Python's ``ssl`` module for outgoing HTTPS connections: + +.. code-block:: python + + from requests_toolbelt import SSLAdapter + import requests + import ssl + + s = requests.Session() + s.mount('https://', SSLAdapter(ssl.PROTOCOL_TLSv1)) + +cookies/ForgetfulCookieJar +-------------------------- + +The ``ForgetfulCookieJar`` prevents a particular requests session from storing +cookies: + +.. code-block:: python + + from requests_toolbelt.cookies.forgetful import ForgetfulCookieJar + + session = requests.Session() + session.cookies = ForgetfulCookieJar() + +Contributing +------------ + +Please read the `suggested workflow +`_ for +contributing to this project. + +Please report any bugs on the `issue tracker`_ + +.. _Cory Benfield's blog: https://lukasa.co.uk/2013/01/Choosing_SSL_Version_In_Requests/ +.. _python-requests: https://github.com/kennethreitz/requests +.. _issue tracker: https://github.com/requests/toolbelt/issues diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..202a17a --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,5 @@ +pytest +mock;python_version<"3.3" +pyopenssl +git+git://github.com/sigmavirus24/betamax +trustme diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..1ef33cf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/requests_toolbelt.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/requests_toolbelt.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/requests_toolbelt" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/requests_toolbelt" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/adapters.rst b/docs/adapters.rst new file mode 100644 index 0000000..8a35918 --- /dev/null +++ b/docs/adapters.rst @@ -0,0 +1,268 @@ +.. _adapters: + +Transport Adapters +================== + +The toolbelt comes with several different transport adapters for you to use +with requests. The transport adapters are all kept in +:mod:`requests_toolbelt.adapters` and include + +- :class:`requests_toolbelt.adapters.appengine.AppEngineAdapter` + +- :class:`requests_toolbelt.adapters.fingerprint.FingerprintAdapter` + +- :class:`requests_toolbelt.adapters.socket_options.SocketOptionsAdapter` + +- :class:`requests_toolbelt.adapters.socket_options.TCPKeepAliveAdapter` + +- :class:`requests_toolbelt.adapters.source.SourceAddressAdapter` + +- :class:`requests_toolbelt.adapters.ssl.SSLAdapter` + +- :class:`requests_toolbelt.adapters.host_header_ssl.HostHeaderSSLAdapter` + +- :class:`requests_toolbelt.adapters.x509.X509Adapter` + +AppEngineAdapter +---------------- + +.. versionadded:: 0.6.0 + +As of version 2.10.0, Requests will be capable of supporting Google's App +Engine platform. In order to use Requests on GAE, however, you will need a +custom adapter found here as +:class:`~requests_toolbelt.adapters.appengine.AppEngineAdapter`. There are two +ways to take advantage of this support at the moment: + +#. Using the :class:`~requests_toolbelt.adapters.appengine.AppEngineAdapter` + like every other adapter, e.g., + + .. code-block:: python + + import requests + from requests_toolbelt.adapters import appengine + + s = requests.Session() + s.mount('http://', appengine.AppEngineAdapter()) + s.mount('https://', appengine.AppEngineAdapter()) + +#. By monkey-patching requests to always use the provided adapter: + + .. code-block:: python + + import requests + from requests_toolbelt.adapters import appengine + + appengine.monkeypatch() + +.. _insecure_appengine: + +If you should need to disable certificate validation when monkeypatching (to +force third-party libraries that use Requests to not validate certificates, if +they do not provide API surface to do so, for example), you can disable it: + + .. code-block:: python + + from requests_toolbelt.adapters import appengine + appengine.monkeypatch(validate_certificate=False) + + .. warning:: + + If ``validate_certificate`` is ``False``, the monkeypatched adapter + will *not* validate certificates. This effectively sets the + ``validate_certificate`` argument to urlfetch.Fetch() to ``False``. You + should avoid using this wherever possible. Details can be found in the + `documentation for urlfetch.Fetch()`_. + + .. _documentation for urlfetch.Fetch(): https://cloud.google.com/appengine/docs/python/refdocs/google.appengine.api.urlfetch + +.. autoclass:: requests_toolbelt.adapters.appengine.AppEngineAdapter + +FingerprintAdapter +------------------ + +.. versionadded:: 0.4.0 + +By default, requests will validate a server's certificate to ensure a +connection is secure. In addition to this, the user can provide a fingerprint +of the certificate they're expecting to receive. Unfortunately, the requests +API does not support this fairly rare use-case. When a user needs this extra +validation, they should use the +:class:`~requests_toolbelt.adapters.fingerprint.FingerprintAdapter` class to +perform the validation. + +.. autoclass:: requests_toolbelt.adapters.fingerprint.FingerprintAdapter + +SSLAdapter +---------- + +The ``SSLAdapter`` is the canonical implementation of the adapter proposed on +Cory Benfield's blog, `here`_. This adapter allows the user to choose one of +the SSL/TLS protocols made available in Python's ``ssl`` module for outgoing +HTTPS connections. + +In principle, this shouldn't be necessary: compliant SSL servers should be able +to negotiate the required SSL version. In practice there have been bugs in some +versions of OpenSSL that mean that this negotiation doesn't go as planned. It +can be useful to be able to simply plug in a Transport Adapter that can paste +over the problem. + +For example, suppose you're having difficulty with the server that provides TLS +for GitHub. You can work around it by using the following code:: + + from requests_toolbelt.adapters.ssl import SSLAdapter + + import requests + import ssl + + s = requests.Session() + s.mount('https://github.com/', SSLAdapter(ssl.PROTOCOL_TLSv1)) + +Any future requests to GitHub made through that adapter will automatically +attempt to negotiate TLSv1, and hopefully will succeed. + +.. autoclass:: requests_toolbelt.adapters.ssl.SSLAdapter + +.. _here: https://lukasa.co.uk/2013/01/Choosing_SSL_Version_In_Requests/ + +HostHeaderSSLAdapter +-------------------- + +.. versionadded:: 0.7.0 + +Requests supports SSL Verification by default. However, it relies on +the user making a request with the URL that has the hostname in it. If, +however, the user needs to make a request with the IP address, they cannot +actually verify a certificate against the hostname they want to request. + +To accomodate this very rare need, we've added +:class:`~requests_toolbelt.adapters.host_header_ssl.HostHeaderSSLAdapter`. +Example usage: + +.. code-block:: python + + import requests + from requests_toolbelt.adapters import host_header_ssl + s = requests.Session() + s.mount('https://', host_header_ssl.HostHeaderSSLAdapter()) + s.get("https://93.184.216.34", headers={"Host": "example.org"}) + +.. autoclass:: requests_toolbelt.adapters.host_header_ssl.HostHeaderSSLAdapter + +SourceAddressAdapter +-------------------- + +.. versionadded:: 0.3.0 + +The :class:`~requests_toolbelt.adapters.source.SourceAddressAdapter` allows a +user to specify a source address for their connnection. + +.. autoclass:: requests_toolbelt.adapters.source.SourceAddressAdapter + +SocketOptionsAdapter +-------------------- + +.. versionadded:: 0.4.0 + +.. note:: + + This adapter will only work with requests 2.4.0 or newer. The ability to + set arbitrary socket options does not exist prior to requests 2.4.0. + +The ``SocketOptionsAdapter`` allows a user to pass specific options to be set +on created sockets when constructing the Adapter without subclassing. The +adapter takes advantage of ``urllib3``'s `support`_ for setting arbitrary +socket options for each ``urllib3.connection.HTTPConnection`` (and +``HTTPSConnection``). + +To pass socket options, you need to send a list of three-item tuples. For +example, ``requests`` and ``urllib3`` disable `Nagle's Algorithm`_ by default. +If you need to re-enable it, you would do the following: + +.. code-block:: python + + import socket + import requests + from requests_toolbelt.adapters.socket_options import SocketOptionsAdapter + + nagles = [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)] + session = requests.Session() + for scheme in session.adapters.keys(): + session.mount(scheme, SocketOptionsAdapter(socket_options=nagles)) + +This would re-enable Nagle's Algorithm for all ``http://`` and ``https://`` +connections made with that session. + +.. autoclass:: requests_toolbelt.adapters.socket_options.SocketOptionsAdapter + +.. _support: https://urllib3.readthedocs.org/en/latest/pools.html?highlight=socket_options#urllib3.connection.HTTPConnection.socket_options +.. _Nagle's Algorithm: https://en.wikipedia.org/wiki/Nagle%27s_algorithm + +TCPKeepAliveAdapter +------------------- + +.. versionadded:: 0.4.0 + +.. note:: + + This adapter will only work with requests 2.4.0 or newer. The ability to + set arbitrary socket options does not exist prior to requests 2.4.0. + +The ``TCPKeepAliveAdapter`` allows a user to pass specific keep-alive related +options as keyword parameters as well as arbitrary socket options. + +.. note:: + + Different keep-alive related socket options may not be available for your + platform. Check the socket module for the availability of the following + constants: + + - ``socket.TCP_KEEPIDLE`` + - ``socket.TCP_KEEPCNT`` + - ``socket.TCP_KEEPINTVL`` + + The adapter will silently ignore any option passed for a non-existent + option. + +An example usage of the adapter: + +.. code-block:: python + + import requests + from requests_toolbelt.adapters.socket_options import TCPKeepAliveAdapter + + session = requests.Session() + keep_alive = TCPKeepAliveAdapter(idle=120, count=20, interval=30) + session.mount('https://region-a.geo-1.compute.hpcloudsvc.com', keep_alive) + session.post('https://region-a.geo-1.compute.hpcloudsvc.com/v2/1234abcdef/servers', + # ... + ) + +In this case we know that creating a server on HP Public Cloud can cause +requests to hang without using TCP Keep-Alive. So we mount the adapter +specifically for that domain, instead of adding it to every ``https://`` and +``http://`` request. + +.. autoclass:: requests_toolbelt.adapters.socket_options.TCPKeepAliveAdapter + +X509Adapter +----------- + +Requests supports SSL Verification using a certificate in .pem format by default. +In some cases it is necessary to pass a full cert chain as part of a request or it +is deemed too great a risk to decrypt the certificate into a .pem file. + +For such use cases we have created +:class:`~requests_toolbelt.adapters.x509.X509Adapter`. +Example usage: + +.. code-block:: python + + import requests + from requests_toolbelt.adapters.x509 import X509Adapter + s = requests.Session() + a = X509Adapter(max_retries=3, + cert_bytes=b'...', pk_bytes=b'...', encoding='...') + s.mount('https://', a) + +.. autoclass:: requests_toolbelt.adapters.x509.X509Adapter diff --git a/docs/authentication.rst b/docs/authentication.rst new file mode 100644 index 0000000..487a740 --- /dev/null +++ b/docs/authentication.rst @@ -0,0 +1,142 @@ +.. _authentication: + +Authentication +============== + +requests supports Basic Authentication and HTTP Digest Authentication by +default. There are also a number of third-party libraries for authentication +with: + +- `OAuth `_ + +- `NTLM `_ + +- `Kerberos `_ + +The :mod:`requests_toolbelt.auth` provides extra authentication features in +addition to those. It provides the following authentication classes: + +- :class:`requests_toolbelt.auth.guess.GuessAuth` + +- :class:`requests_toolbelt.auth.http_proxy_digest.HTTPProxyDigestAuth` + +- :class:`requests_toolbelt.auth.handler.AuthHandler` + +AuthHandler +----------- + +The :class:`~requests_toolbelt.auth.handler.AuthHandler` is a way of using a +single session with multiple websites that require authentication. If you know +what websites require a certain kind of authentication and what your +credentials are. + +Take for example a session that needs to authenticate to GitHub's API and +GitLab's API, you would set up and use your +:class:`~requests_toolbelt.auth.handler.AuthHandler` like so: + +.. code-block:: python + + import requests + from requests_toolbelt.auth.handler import AuthHandler + + def gitlab_auth(request): + request.headers['PRIVATE-TOKEN'] = 'asecrettoken' + + handler = AuthHandler({ + 'https://api.github.com': ('sigmavirus24', 'apassword'), + 'https://gitlab.com': gitlab_auth, + }) + + session = requests.Session() + session.auth = handler + r = session.get('https://api.github.com/user') + # assert r.ok + r2 = session.get('https://gitlab.com/api/v3/projects') + # assert r2.ok + +.. note:: + + You **must** provide both the scheme and domain for authentication. The + :class:`~requests_toolbelt.auth.handler.AuthHandler` class will check both + the scheme and host to ensure your data is not accidentally exposed. + +.. autoclass:: requests_toolbelt.auth.handler.AuthHandler + :members: + +GuessAuth +--------- + +The :class:`~requests_toolbelt.auth.guess.GuessAuth` authentication class +automatically detects whether to use basic auth or digest auth: + +.. code-block:: python + + import requests + from requests_toolbelt.auth import GuessAuth + + requests.get('http://httpbin.org/basic-auth/user/passwd', + auth=GuessAuth('user', 'passwd')) + requests.get('http://httpbin.org/digest-auth/auth/user/passwd', + auth=GuessAuth('user', 'passwd')) + +Detection of the auth type is done via the ``WWW-Authenticate`` header sent by +the server. This requires an additional request in case of basic auth, as +usually basic auth is sent preemptively. If the server didn't explicitly +require authentication, no credentials are sent. + +.. autoclass:: requests_toolbelt.auth.guess.GuessAuth + + +GuessProxyAuth +-------------- + +The :class:`~requests_toolbelt.auth.guess.GuessProxyAuth` handler will +automatically detect whether to use basic authentication or digest authentication +when authenticating to the provided proxy. + +.. code-block:: python + + import requests + from requests_toolbelt.auth.guess import GuessProxyAuth + + proxies = { + "http": "http://PROXYSERVER:PROXYPORT", + "https": "http://PROXYSERVER:PROXYPORT", + } + requests.get('http://httpbin.org/basic-auth/user/passwd', + auth=GuessProxyAuth('user', 'passwd', 'proxyusr', 'proxypass'), + proxies=proxies) + requests.get('http://httpbin.org/digest-auth/auth/user/passwd', + auth=GuessProxyAuth('user', 'passwd', 'proxyusr', 'proxypass'), + proxies=proxies) + +Detection of the auth type is done via the ``Proxy-Authenticate`` header sent by +the server. This requires an additional request in case of basic auth, as +usually basic auth is sent preemptively. If the server didn't explicitly +require authentication, no credentials are sent. + +.. autoclass:: requests_toolbelt.auth.guess.GuessProxyAuth + +HTTPProxyDigestAuth +------------------- + +The ``HTTPProxyDigestAuth`` use digest authentication between the client and +the proxy. + +.. code-block:: python + + import requests + from requests_toolbelt.auth.http_proxy_digest import HTTPProxyDigestAuth + + + proxies = { + "http": "http://PROXYSERVER:PROXYPORT", + "https": "https://PROXYSERVER:PROXYPORT", + } + url = "https://toolbelt.readthedocs.io/" + auth = HTTPProxyDigestAuth("USERNAME", "PASSWORD") + requests.get(url, proxies=proxies, auth=auth) + +Program would raise error if the username or password is rejected by the proxy. + +.. autoclass:: requests_toolbelt.auth.http_proxy_digest.HTTPProxyDigestAuth diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..9f45876 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- +# +# requests_toolbelt documentation build configuration file, created by +# sphinx-quickstart on Sun Jan 12 21:24:39 2014. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +import os +import sys +sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath('..')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'requests_toolbelt' +copyright = u'2015, Ian Cordasco, Cory Benfield' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +from requests_toolbelt import __version__ as version +# The full version, including alpha/beta/rc tags. +release = version + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +if not on_rtd: # only import and set the theme if we're building docs locally + import sphinx_rtd_theme + html_theme = 'sphinx_rtd_theme' + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +#html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +#html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'requests_toolbelt-doc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'requests_toolbelt.tex', u'requests\\_toolbelt Documentation', + u'Ian Cordasco, Cory Benfield', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'requests_toolbelt', u'requests_toolbelt Documentation', + [u'Ian Cordasco, Cory Benfield'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'requests_toolbelt', u'requests_toolbelt Documentation', + u'Ian Cordasco, Cory Benfield', 'requests_toolbelt', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 0000000..d8ef429 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,161 @@ +Contributing to this project +============================ + +Checklist +--------- + +#. All potential contributors must read the :ref:`code-of-conduct` and follow + it + +#. Fork the repository on `GitHub`_ or `GitLab`_ + +#. Create a new branch, e.g., ``git checkout -b bug/12345`` + +#. Fix the bug and add tests (if applicable [#]_, see :ref:`how-to-add-tests`) + +#. Run the tests (see :ref:`how-to-run-tests` below) + +#. Add documentation (as necessary) for your change + +#. Build the documentation to check for errors and formatting (see + :ref:`how-to-build-the-docs` below) + +#. Add yourself to the :file:`AUTHORS.rst` (unless you're already there) + +#. Commit it. Follow these rules in your commit message: + + * Keep the subject line under 50 characters + + * Use an imperative verb to start the commit + + * Use an empty line between the subject and the message + + * Describe the *why* in detail in the message portion of the commit + + * Wrap the lines of the message at 72 characters + + * Add the appropriate "Closes #12345" syntax to autoclose the issue it + fixed (if it closes an issue) + + * See :ref:`example-commit-message` below + +#. Push it to your fork + +#. Create a request for us to merge your contribution + +After this last step, it is possible that we may leave feedback in the form of +review comments. When addressing these comments, you can follow two +strategies: + +* Amend/rebase your changes into an existing commit + +* Create a new commit with a different message [#]_ describing the changes in + that commit and push it to your branch + +This project is not opinionated about which approach you should prefer. We +only ask that you are aware of the following: + +* Neither GitHub nor GitLab notifies us that you have pushed new changes. A + friendly ping is encouraged + +* If you continue to use the same branch that you created the request from, + both GitHub and GitLab will update the request on the website. You do + **not** need to create a new request for the new changes. + + +.. _code-of-conduct: + +.. include:: ../CODE_OF_CONDUCT.rst + +.. _how-to-add-tests: + +How To Add Tests +---------------- + +We use `pytest`_ to run tests and to simplify how we write tests. If you're +fixing a bug in an existing please find tests for that module or feature and +add to them. Most tests live in the ``tests`` directory. If you're adding a +new feature in a new submodule, please create a new module of test code. For +example, if you're adding a submodule named ``foo`` then you would create +``tests/test_foo.py`` which will contain the tests for the ``foo`` submodule. + +.. _how-to-run-tests: + +How To Run The Tests +-------------------- + +Run the tests in this project using `tox`_. Before you run the tests, ensure +you have installed tox either using your system package manager (e.g., apt, +yum, etc.), or your prefered python installer (e.g., pip). + +Then run the tests on at least Python 2.7 and Python 3.x, e.g., + +.. code:: + + $ tox -e py27,py34 + +Finally run one, or both, of the flake8 style enforcers, e.g., + +.. code:: + + $ tox -e py27-flake8 + # or + $ tox -e py34-flake8 + +It is preferable if you run both to catch syntax errors that might occur in +Python 2 or Python 3 (based on how familiar you are with the common subset of +language from both). + +Tox will manage virtual environments and dependencies for you so it will be +the only dependency you need to install to contribute to this project. + +.. _how-to-build-the-docs: + +How To Build The Documentation +------------------------------ + +To build the docs, you need to ensure tox is installed and then you may run + +.. code:: + + $ tox -e docs + +This will build the documentation into ``docs/_build/html``. If you then run + +.. code:: + + $ python2.7 -m SimpleHTTPServer + # or + $ python3.4 -m http.server + +from that directory, you can view the docs locally at http://localhost:8000/. + +.. _example-commit-message: + +Example Commit Message +---------------------- + +:: + + Allow users to use the frob when uploading data + + When uploading data with FooBar, users may need to use the frob method + to ensure that pieces of data are not munged. + + Closes #1234567 + +Footnotes +--------- + +.. [#] You might not need tests if you're updating documentation, fixing a + typo, or updating a docstring. If you're fixing a bug, please add + tests. + +.. [#] If each commit has the same message, the reviewer may ask you to + squash your commits or may squash them for you and perform a manual + merge. + +.. _GitHub: https://github.com/requests/toolbelt +.. _GitLab: https://gitlab.com/sigmavirus24/toolbelt +.. _tox: https://tox.readthedocs.io/ +.. _pytest: https://docs.pytest.org/ diff --git a/docs/deprecated.rst b/docs/deprecated.rst new file mode 100644 index 0000000..e733d32 --- /dev/null +++ b/docs/deprecated.rst @@ -0,0 +1,13 @@ +.. _deprecated: + +Deprecated Requests Utilities +============================= + +Requests has `decided`_ to deprecate some utility functions in +:mod:`requests.utils`. To ease users' lives, they've been moved to +:mod:`requests_toolbelt.utils.deprecated`. + +.. automodule:: requests_toolbelt.utils.deprecated + :members: + +.. _decided: https://github.com/kennethreitz/requests/issues/2266 diff --git a/docs/downloadutils.rst b/docs/downloadutils.rst new file mode 100644 index 0000000..bcd0f2f --- /dev/null +++ b/docs/downloadutils.rst @@ -0,0 +1,16 @@ +.. _downloadutils: + +Utilities for Downloading Streaming Responses +============================================= + +.. autofunction:: + requests_toolbelt.downloadutils.stream.stream_response_to_file + +.. autofunction:: + requests_toolbelt.downloadutils.tee.tee + +.. autofunction:: + requests_toolbelt.downloadutils.tee.tee_to_bytearray + +.. autofunction:: + requests_toolbelt.downloadutils.tee.tee_to_file diff --git a/docs/dumputils.rst b/docs/dumputils.rst new file mode 100644 index 0000000..052ec61 --- /dev/null +++ b/docs/dumputils.rst @@ -0,0 +1,17 @@ +.. _dumputils: + +Utilities for Dumping Information About Responses +================================================= + +Occasionally, it is helpful to know almost exactly what data was sent to a +server and what data was received. It can also be challenging at times to +gather all of that data from requests because of all of the different places +you may need to look to find it. In :mod:`requests_toolbelt.utils.dump` there +are two functions that will return a :class:`bytearray` with the information +retrieved from a response object. + +.. autofunction:: + requests_toolbelt.utils.dump.dump_all + +.. autofunction:: + requests_toolbelt.utils.dump.dump_response diff --git a/docs/exceptions.rst b/docs/exceptions.rst new file mode 100644 index 0000000..e10a244 --- /dev/null +++ b/docs/exceptions.rst @@ -0,0 +1,10 @@ +.. _exceptions: + +Custom Toolbelt Exceptions +========================== + +Below are the exception classes used by the toolbelt to provide error details +to the user of the toolbelt. + +.. automodule:: requests_toolbelt.exceptions + :members: diff --git a/docs/formdata.rst b/docs/formdata.rst new file mode 100644 index 0000000..0d48d27 --- /dev/null +++ b/docs/formdata.rst @@ -0,0 +1,7 @@ +.. _formdatautils: + +Utilities for Enhanced Form-Data Serialization +============================================== + +.. autofunction:: + requests_toolbelt.utils.formdata.urlencode diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..369b711 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,49 @@ +.. requests_toolbelt documentation master file, created by + sphinx-quickstart on Sun Jan 12 21:24:39 2014. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +requests toolbelt +================= + +This is a collection of utilities that some users of python-requests might need +but do not belong in requests proper. The library is actively maintained by +members of the requests core development team, and so reflects the +functionality most requested by users of the requests library. + +To get an overview of what the library contains, consult the :ref:`user ` +documentation. + +Overview +-------- + +.. toctree:: + :maxdepth: 1 + + user + contributing + +Full Documentation +------------------ + +.. toctree:: + :maxdepth: 2 + + adapters + authentication + deprecated + downloadutils + dumputils + formdata + exceptions + sessions + threading + uploading-data + user-agent + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..45ea43d --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,242 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\requests_toolbelt.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\requests_toolbelt.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/sessions.rst b/docs/sessions.rst new file mode 100644 index 0000000..924e6dc --- /dev/null +++ b/docs/sessions.rst @@ -0,0 +1,24 @@ +.. _sessions: + +Specialized Sessions +==================== + +The toolbelt provides specialized session classes in the +:mod:`requests_toolbelt.sessions` module. + +.. automodule:: requests_toolbelt.sessions + :members: + + +BaseUrlSession +-------------- + +.. versionadded:: 0.7.0 + +Many people have written Session subclasses that allow a "base URL" to be +specified so all future requests need not specify the complete URL. To create +one simplified and easy to configure version, we've added the +:class:`requests_toolbelt.sessions.BaseUrlSession` object to the Toolbelt. + +.. autoclass:: requests_toolbelt.sessions.BaseUrlSession + :members: diff --git a/docs/threading.rst b/docs/threading.rst new file mode 100644 index 0000000..d6df1e5 --- /dev/null +++ b/docs/threading.rst @@ -0,0 +1,170 @@ +.. _threading: + +Using requests with Threading +============================= + +.. versionadded:: 0.4.0 + +The toolbelt provides a simple API for using requests with threading. + +A requests Session is documented as threadsafe but there are still a couple +corner cases where it isn't perfectly threadsafe. The best way to use a +Session is to use one per thread. + +The implementation provided by the toolbelt is naïve. This means that we use +one session per thread and we make no effort to synchronize attributes (e.g., +authentication, cookies, etc.). It also means that we make no attempt to +direct a request to a session that has already handled a request to the same +domain. In other words, if you're making requests to multiple domains, the +toolbelt's Pool will not try to send requests to the same domain to the same +thread. + +This module provides three classes: + +- :class:`~requests_toolbelt.threaded.pool.Pool` +- :class:`~requests_toolbelt.threaded.pool.ThreadResponse` +- :class:`~requests_toolbelt.threaded.pool.ThreadException` + +In 98% of the situations you'll want to just use a +:class:`~requests_toolbelt.threaded.pool.Pool` and you'll treat a +:class:`~requests_toolbelt.threaded.pool.ThreadResponse` as if it were a +regular :class:`requests.Response`. + +Here's an example: + +.. code-block:: python + + # This example assumes Python 3 + import queue + from requests_toolbelt.threaded import pool + + jobs = queue.Queue() + urls = [ + # My list of URLs to get + ] + + for url in urls: + queue.put({'method': 'GET', 'url': url}) + + p = pool.Pool(job_queue=q) + p.join_all() + + for response in p.responses(): + print('GET {}. Returned {}.'.format(response.request_kwargs['url'], + response.status_code)) + +This is clearly a bit underwhelming. This is why there's a short-cut class +method to create a :class:`~requests_toolbelt.threaded.pool.Pool` from a list +of URLs. + +.. code-block:: python + + from requests_toolbelt.threaded import pool + + urls = [ + # My list of URLs to get + ] + + p = pool.Pool.from_urls(urls) + p.join_all() + + for response in p.responses(): + print('GET {}. Returned {}.'.format(response.request_kwargs['url'], + response.status_code)) + +If one of the URLs in your list throws an exception, it will be accessible +from the :meth:`~Pool.exceptions` generator. + +.. code-block:: python + + from requests_toolbelt.threaded import pool + + urls = [ + # My list of URLs to get + ] + + p = pool.Pool.from_urls(urls) + p.join_all() + + for exc in p.exceptions(): + print('GET {}. Raised {}.'.format(exc.request_kwargs['url'], + exc.message)) + +If instead, you want to retry the exceptions that have been raised you can do +the following: + +.. code-block:: python + + from requests_toolbelt.threaded import pool + + urls = [ + # My list of URLs to get + ] + + p = pool.Pool.from_urls(urls) + p.join_all() + + new_pool = pool.Pool.from_exceptions(p.exceptions()) + new_pool.join_all() + +Not all requests are advisable to retry without checking if they should be +retried. You would normally check if you want to retry it. + +The :class:`~Pool` object takes 4 other keyword arguments: + +- ``initializer`` + + This is a callback that will initialize things on every session created. The + callback must return the session. + +- ``auth_generator`` + + This is a callback that is called *after* the initializer callback has + modified the session. This callback must also return the session. + +- ``num_processes`` + + By passing a positive integer that indicates how many threads to use. It is + ``None`` by default, and will use the result of + ``multiproccessing.cpu_count()``. + +- ``session`` + + You can pass an alternative constructor or any callable that returns a + :class:`requests.Sesssion` like object. It will not be passed any arguments + because a :class:`requests.Session` does not accept any arguments. + +Finally, if you don't want to worry about Queue or Pool management, you can +try the following: + +.. code-block:: python + + from requests_toolbelt import threaded + + requests = [{ + 'method': 'GET', + 'url': 'https://httpbin.org/get', + # ... + }, { + # ... + }, { + # ... + }] + + responses_generator, exceptions_generator = threaded.map(requests) + for response in responses_generator: + # Do something + +API and Module Auto-Generated Documentation +------------------------------------------- + +.. automodule:: requests_toolbelt.threaded + +.. autoclass:: requests_toolbelt.threaded.pool.Pool + :members: + +.. autoclass:: requests_toolbelt.threaded.pool.ThreadResponse + :members: + +.. autoclass:: requests_toolbelt.threaded.pool.ThreadException + :members: diff --git a/docs/uploading-data.rst b/docs/uploading-data.rst new file mode 100644 index 0000000..aec2228 --- /dev/null +++ b/docs/uploading-data.rst @@ -0,0 +1,172 @@ +.. _uploading-data: + +Uploading Data +============== + +Streaming Multipart Data Encoder +-------------------------------- + +Requests has `support for multipart uploads`_, but the API means that using +that functionality to build exactly the Multipart upload you want can be +difficult or impossible. Additionally, when using Requests' Multipart upload +functionality all the data must be read into memory before being sent to the +server. In extreme cases, this can make it impossible to send a file as part of +a ``multipart/form-data`` upload. + +The toolbelt contains a class that allows you to build multipart request bodies +in exactly the format you need, and to avoid reading files into memory. An +example of how to use it is like this: + +.. code-block:: python + + import requests + from requests_toolbelt.multipart.encoder import MultipartEncoder + + m = MultipartEncoder( + fields={'field0': 'value', 'field1': 'value', + 'field2': ('filename', open('file.py', 'rb'), 'text/plain')} + ) + + r = requests.post('http://httpbin.org/post', data=m, + headers={'Content-Type': m.content_type}) + +The :class:`~requests_toolbelt.multipart.encoder.MultipartEncoder` has the +``.to_string()`` convenience method, as well. This method renders the +multipart body into a string. This is useful when developing your code, +allowing you to confirm that the multipart body has the form you expect before +you send it on. + +The toolbelt also provides a way to monitor your streaming uploads with +the :class:`~requests_toolbelt.multipart.encoder.MultipartEncoderMonitor`. + +.. autoclass:: requests_toolbelt.multipart.encoder.MultipartEncoder + +.. _support for multipart uploads: http://docs.python-requests.org/en/latest/user/quickstart/#post-a-multipart-encoded-file + +Monitoring Your Streaming Multipart Upload +------------------------------------------ + +If you need to stream your ``multipart/form-data`` upload then you're probably +in the situation where it might take a while to upload the content. In these +cases, it might make sense to be able to monitor the progress of the upload. +For this reason, the toolbelt provides the +:class:`~requests_toolbelt.multipart.encoder.MultipartEncoderMonitor`. The +monitor wraps an instance of a +:class:`~requests_toolbelt.multipart.encoder.MultipartEncoder` and is used +exactly like the encoder. It provides a similar API with some additions: + +- The monitor accepts a function as a callback. The function is called every + time ``requests`` calls ``read`` on the monitor and passes in the monitor as + an argument. + +- The monitor tracks how many bytes have been read in the course of the + upload. + +You might use the monitor to create a progress bar for the upload. Here is `an +example using clint`_ which displays the progress bar. + +To use the monitor you would follow a pattern like this: + +.. code-block:: python + + import requests + from requests_toolbelt.multipart import encoder + + def my_callback(monitor): + # Your callback function + pass + + e = encoder.MultipartEncoder( + fields={'field0': 'value', 'field1': 'value', + 'field2': ('filename', open('file.py', 'rb'), 'text/plain')} + ) + m = encoder.MultipartEncoderMonitor(e, my_callback) + + r = requests.post('http://httpbin.org/post', data=m, + headers={'Content-Type': m.content_type}) + +If you have a very simple use case you can also do: + +.. code-block:: python + + import requests + from requests_toolbelt.multipart.encoder import MultipartEncoderMonitor + + def my_callback(monitor): + # Your callback function + pass + + m = MultipartEncoderMonitor.from_fields( + fields={'field0': 'value', 'field1': 'value', + 'field2': ('filename', open('file.py', 'rb'), 'text/plain')}, + callback=my_callback + ) + + r = requests.post('http://httpbin.org/post', data=m, + headers={'Content-Type': m.content_type}) + + +.. autoclass:: requests_toolbelt.multipart.encoder.MultipartEncoderMonitor + +.. _an example using clint: + https://github.com/requests/toolbelt/blob/master/examples/monitor/progress_bar.py + +Streaming Data from a Generator +------------------------------- + +There are cases where you, the user, have a generator of some large quantity +of data and you already know the size of that data. If you pass the generator +to ``requests`` via the ``data`` parameter, ``requests`` will assume that you +want to upload the data in chunks and set a ``Transfer-Encoding`` header value +of ``chunked``. Often times, this causes the server to behave poorly. If you +want to avoid this, you can use the +:class:`~requests.toolbelt.streaming_iterator.StreamingIterator`. You pass it +the size of the data and the generator. + +.. code-block:: python + + import requests + from requests_toolbelt.streaming_iterator import StreamingIterator + + generator = some_function() # Create your generator + size = some_function_size() # Get your generator's size + content_type = content_type() # Get the content-type of the data + + streamer = StreamingIterator(size, generator) + r = requests.post('https://httpbin.org/post', data=streamer, + headers={'Content-Type': content_type}) + +The streamer will handle your generator for you and buffer the data before +passing it to ``requests``. + +.. versionchanged:: 0.4.0 + + File-like objects can be passed instead of a generator. + +If, for example, you need to upload data being piped into standard in, you +might otherwise do: + +.. code-block:: python + + import requests + import sys + + r = requests.post(url, data=sys.stdin) + +This would stream the data but would use a chunked transfer-encoding. If +instead, you know the length of the data that is being sent to ``stdin`` and +you want to prevent the data from being uploaded in chunks, you can use the +:class:`~requests_toolbelt.streaming_iterator.StreamingIterator` to stream the +contents of the file without relying on chunking. + +.. code-block:: python + + import requests + from requests_toolbelt.streaming_iterator import StreamingIterator + import sys + + stream = StreamingIterator(size, sys.stdin) + r = requests.post(url, data=stream, + headers={'Content-Type': content_type}) + +.. autoclass:: requests_toolbelt.streaming_iterator.StreamingIterator diff --git a/docs/user-agent.rst b/docs/user-agent.rst new file mode 100644 index 0000000..c8e56e9 --- /dev/null +++ b/docs/user-agent.rst @@ -0,0 +1,94 @@ +.. _user-agent: + +User-Agent Constructor +====================== + +Having well-formed user-agent strings is important for the proper functioning +of the web. Make server administators happy by generating yourself a nice +user-agent string, just like Requests does! The output of the user-agent +generator looks like this:: + + >>> import requests_toolbelt + >>> requests_toolbelt.user_agent('mypackage', '0.0.1') + 'mypackage/0.0.1 CPython/2.7.5 Darwin/13.0.0' + +The Python type and version, and the platform type and version, will accurately +reflect the system that your program is running on. You can drop this easily +into your program like this:: + + from requests_toolbelt import user_agent + from requests import Session + + s = Session() + s.headers = { + 'User-Agent': user_agent('my_package', '0.0.1') + } + + r = s.get('https://api.github.com/users') + +This will override the default Requests user-agent string for all of your HTTP +requests, replacing it with your own. + +Adding Extra Information to Your User-Agent String +-------------------------------------------------- + +.. versionadded:: 0.5.0 + +If you feel it necessary, you can also include versions for other things that +your client is using. For example if you were building a package and wanted to +include the package name and version number as well as the version of requests +and requests-toolbelt you were using you could do the following: + +.. code-block:: python + + import requests + import requests_toolbelt + from requests_toolbelt.utils import user_agent as ua + + user_agent = ua.user_agent('mypackage', '0.0.1', + extras=[('requests', requests.__version__), + ('requests-toolbelt', requests_toolbelt.__version__)]) + + s = requests.Session() + s.headers['User-Agent'] = user_agent + + +Your user agent will now look like:: + + mypackage/0.0.1 requests/2.7.0 requests-toolbelt/0.5.0 CPython/2.7.10 Darwin/13.0.0 + +Selecting Only What You Want +---------------------------- + +.. versionadded:: 0.8.0 + +While most people will find the ``user_agent`` function sufficient for their +usage, others will want to control exactly what information is included in the +User-Agent. For those people, the +:class:`~requests_toolbelt.utils.user_agent.UserAgentBuilder` is the correct +tool. This is the tool that the toolbelt uses inside of +:func:`~requests_toolbelt.utils.user_agent.user_agent`. For example, let's say +you *only* want your package, its versions, and some extra information, in +that case you would do: + +.. code-block:: python + + import requests + from requests_toolbelt.utils import user_agent as ua + + s = requests.Session() + s.headers['User-Agent'] = ua.UserAgentBuilder( + 'mypackage', '0.0.1', + ).include_extras([ + ('requests', requests.__version__), + ]).build() + +Your user agent will now look like:: + + mypackage/0.0.1 requests/2.7.0 + +You can also optionally include the Python version information and System +information the same way that our ``user_agent`` function does. + +.. autoclass:: requests_toolbelt.utils.user_agent.UserAgentBuilder + :members: diff --git a/docs/user.rst b/docs/user.rst new file mode 100644 index 0000000..9493d33 --- /dev/null +++ b/docs/user.rst @@ -0,0 +1,3 @@ +.. _user: + +.. include:: ../README.rst diff --git a/requests_toolbelt.egg-info/PKG-INFO b/requests_toolbelt.egg-info/PKG-INFO new file mode 100644 index 0000000..228940d --- /dev/null +++ b/requests_toolbelt.egg-info/PKG-INFO @@ -0,0 +1,496 @@ +Metadata-Version: 2.1 +Name: requests-toolbelt +Version: 0.10.1 +Summary: A utility belt for advanced users of python-requests +Home-page: https://toolbelt.readthedocs.io/ +Author: Ian Cordasco, Cory Benfield +Author-email: graffatcolmingov@gmail.com +License: Apache 2.0 +Classifier: Development Status :: 5 - Production/Stable +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* +Description-Content-Type: text/x-rst +License-File: LICENSE +License-File: AUTHORS.rst + +The Requests Toolbelt +===================== + +This is just a collection of utilities for `python-requests`_, but don't +really belong in ``requests`` proper. The minimum tested requests version is +``2.1.0``. In reality, the toolbelt should work with ``2.0.1`` as well, but +some idiosyncracies prevent effective or sane testing on that version. + +``pip install requests-toolbelt`` to get started! + + +multipart/form-data Encoder +--------------------------- + +The main attraction is a streaming multipart form-data object, ``MultipartEncoder``. +Its API looks like this: + +.. code-block:: python + + from requests_toolbelt import MultipartEncoder + import requests + + m = MultipartEncoder( + fields={'field0': 'value', 'field1': 'value', + 'field2': ('filename', open('file.py', 'rb'), 'text/plain')} + ) + + r = requests.post('http://httpbin.org/post', data=m, + headers={'Content-Type': m.content_type}) + + +You can also use ``multipart/form-data`` encoding for requests that don't +require files: + +.. code-block:: python + + from requests_toolbelt import MultipartEncoder + import requests + + m = MultipartEncoder(fields={'field0': 'value', 'field1': 'value'}) + + r = requests.post('http://httpbin.org/post', data=m, + headers={'Content-Type': m.content_type}) + + +Or, you can just create the string and examine the data: + +.. code-block:: python + + # Assuming `m` is one of the above + m.to_string() # Always returns unicode + + +User-Agent constructor +---------------------- + +You can easily construct a requests-style ``User-Agent`` string:: + + from requests_toolbelt import user_agent + + headers = { + 'User-Agent': user_agent('my_package', '0.0.1') + } + + r = requests.get('https://api.github.com/users', headers=headers) + + +SSLAdapter +---------- + +The ``SSLAdapter`` was originally published on `Cory Benfield's blog`_. +This adapter allows the user to choose one of the SSL protocols made available +in Python's ``ssl`` module for outgoing HTTPS connections: + +.. code-block:: python + + from requests_toolbelt import SSLAdapter + import requests + import ssl + + s = requests.Session() + s.mount('https://', SSLAdapter(ssl.PROTOCOL_TLSv1)) + +cookies/ForgetfulCookieJar +-------------------------- + +The ``ForgetfulCookieJar`` prevents a particular requests session from storing +cookies: + +.. code-block:: python + + from requests_toolbelt.cookies.forgetful import ForgetfulCookieJar + + session = requests.Session() + session.cookies = ForgetfulCookieJar() + +Contributing +------------ + +Please read the `suggested workflow +`_ for +contributing to this project. + +Please report any bugs on the `issue tracker`_ + +.. _Cory Benfield's blog: https://lukasa.co.uk/2013/01/Choosing_SSL_Version_In_Requests/ +.. _python-requests: https://github.com/kennethreitz/requests +.. _issue tracker: https://github.com/requests/toolbelt/issues + + +History +======= + +0.10.1 -- 2022-10-25 +-------------------- + +Fixed Bugs +~~~~~~~~~~ + +- Fix urllib3 warning to only emit on X509Adapter usage + +0.10.0 -- 2022-10-06 +-------------------- + +New Features +~~~~~~~~~~~~ + +- Add support for preparing requests in BaseUrlSession + +Fixed Bugs +~~~~~~~~~~ + +- Fixing missing newline in dump utility + +0.9.1 -- 2019-01-29 +------------------- + +Fixed Bugs +~~~~~~~~~~ + +- Fix import of pyOpenSSL shim from urllib3 for PKCS12 adapter + +0.9.0 -- 2019-01-29 +------------------- + +New Features +~~~~~~~~~~~~ + +- Add X509 Adapter that can handle PKCS12 +- Add stateless solution for streaming files by MultipartEncoder from one host to another (in chunks) + +Fixed Bugs +~~~~~~~~~~ + +- Update link to example +- Move import of ``ABCs`` from collections into version-specific part of + _compat module +- Fix backwards incompatibility in ``get_encodings_from_content`` +- Correct callback documentation for ``MultipartEncoderMonitor`` +- Fix bug when ``MultipartEncoder`` is asked to encode zero parts +- Correct the type of non string request body dumps +- Removed content from being stored in MultipartDecoder +- Fix bug by enabling support for contenttype with capital letters. +- Coerce proxy URL to bytes before dumping request +- Avoid bailing out with exception upon empty response reason +- Corrected Pool documentation +- Corrected parentheses match in example usage +- Fix "oject" to "object" in ``MultipartEncoder`` +- Fix URL for the project after the move +- Add fix for OSX TCPKeepAliveAdapter + +Miscellaneous +~~~~~~~~~~~~~ + +- Remove py33 from testing and add Python 3.6 and nightly testing to the travis matrix. + +0.8.0 -- 2017-05-20 +------------------- + +More information about this release can be found on the `0.8.0 milestone`_. + +New Features +~~~~~~~~~~~~ + +- Add ``UserAgentBuilder`` to provide more control over generated User-Agent + strings. + +Fixed Bugs +~~~~~~~~~~ + +- Include ``_validate_certificate`` in the lits of picked attributes on the + ``AppEngineAdapter``. +- Fix backwards incompatibility in ``get_encodings_from_content`` + +.. _0.8.0 milestone: + https://github.com/requests/toolbelt/milestones/0.8.0 + +0.7.1 -- 2017-02-13 +------------------- + +More information about this release can be found on the `0.7.1 milestone`_. + +Fixed Bugs +~~~~~~~~~~ + +- Fixed monkey-patching for the AppEngineAdapter. + +- Make it easier to disable certificate verification when monkey-patching + AppEngine. + +- Handle ``multipart/form-data`` bodies without a trailing ``CRLF``. + + +.. links +.. _0.7.1 milestone: + https://github.com/requests/toolbelt/milestone/9 + +0.7.0 -- 2016-07-21 +------------------- + +More information about this release can be found on the `0.7.0 milestone`_. + +New Features +~~~~~~~~~~~~ + +- Add ``BaseUrlSession`` to allow developers to have a session that has a + "Base" URL. See the documentation for more details and examples. + +- Split the logic of ``stream_response_to_file`` into two separate functions: + + * ``get_download_file_path`` to generate the file name from the Response. + + * ``stream_response_to_file`` which will use ``get_download_file_path`` if + necessary + +Fixed Bugs +~~~~~~~~~~ + +- Fixed the issue for people using *very* old versions of Requests where they + would see an ImportError from ``requests_toolbelt._compat`` when trying to + import ``connection``. + + +.. _0.7.0 milestone: + https://github.com/requests/toolbelt/milestones/0.7.0 + +0.6.2 -- 2016-05-10 +------------------- + +Fixed Bugs +~~~~~~~~~~ + +- When passing a timeout via Requests, it was not appropriately translated to + the timeout that the urllib3 code was expecting. + +0.6.1 -- 2016-05-05 +------------------- + +Fixed Bugs +~~~~~~~~~~ + +- Remove assertion about request URLs in the AppEngineAdapter. + +- Prevent pip from installing requests 3.0.0 when that is released until we + are ready to handle it. + +0.6.0 -- 2016-01-27 +------------------- + +More information about this release can be found on the `0.6.0 milestone`_. + +New Features +~~~~~~~~~~~~ + +- Add ``AppEngineAdapter`` to support developers using Google's AppEngine + platform with Requests. + +- Add ``GuessProxyAuth`` class to support guessing between Basic and Digest + Authentication for proxies. + +Fixed Bugs +~~~~~~~~~~ + +- Ensure that proxies use the correct TLS version when using the + ``SSLAdapter``. + +- Fix an ``AttributeError`` when using the ``HTTPProxyDigestAuth`` class. + +Miscellaneous +~~~~~~~~~~~~~ + +- Drop testing support for Python 3.2. virtualenv and pip have stopped + supporting it meaning that it is harder to test for this with our CI + infrastructure. Moving forward we will make a best-effort attempt to + support 3.2 but will not test for it. + + +.. _0.6.0 milestone: + https://github.com/requests/toolbelt/milestones/0.6.0 + +0.5.1 -- 2015-12-16 +------------------- + +More information about this release can be found on the `0.5.1 milestone`_. + +Fixed Bugs +~~~~~~~~~~ + +- Now papers over the differences in requests' ``super_len`` function from + versions prior to 2.9.0 and versions 2.9.0 and later. + + +.. _0.5.1 milestone: + https://github.com/requests/toolbelt/milestones/0.5.1 + +0.5.0 -- 2015-11-24 +------------------- + +More information about this release can be found on the `milestone +`_ +for 0.5.0. + +New Features +~~~~~~~~~~~~ + +- The ``tee`` submodule was added to ``requests_toolbelt.downloadutils``. It + allows you to iterate over the bytes of a response while also writing them + to a file. The ``tee.tee`` function, expects you to pass an open file + object, while ``tee.tee_to_file`` will use the provided file name to open + the file for you. + +- Added a new parameter to ``requests_toolbelt.utils.user_agent`` that allows + the user to specify additional items. + +- Added nested form-data helper, + ``requests_toolbelt.utils.formdata.urlencode``. + +- Added the ``ForgetfulCookieJar`` to ``requests_toolbelt.cookies``. + +- Added utilities for dumping the information about a request-response cycle + in ``requests_toolbelt.utils.dump``. + +- Implemented the API described in the ``requests_toolbelt.threaded`` module + docstring, i.e., added ``requests_toolbelt.threaded.map`` as an available + function. + +Fixed Bugs +~~~~~~~~~~ + +- Now papers over the API differences in versions of requests installed from + system packages versus versions of requests installed from PyPI. + +- Allow string types for ``SourceAddressAdapter``. + +0.4.0 -- 2015-04-03 +------------------- + +For more information about this release, please see `milestone 0.4.0 +`_ +on the project's page. + +New Features +~~~~~~~~~~~~ + +- A naive implemenation of a thread pool is now included in the toolbelt. See + the docs in ``docs/threading.rst`` or on `Read The Docs + `_. + +- The ``StreamingIterator`` now accepts files (such as ``sys.stdin``) without + a specific length and will properly stream them. + +- The ``MultipartEncoder`` now accepts exactly the same format of fields as + requests' ``files`` parameter does. In other words, you can now also pass in + extra headers to add to a part in the body. You can also now specify a + custom ``Content-Type`` for a part. + +- An implementation of HTTP Digest Authentication for Proxies is now included. + +- A transport adapter that allows a user to specify a specific Certificate + Fingerprint is now included in the toolbelt. + +- A transport adapter that simplifies how users specify socket options is now + included. + +- A transport adapter that simplifies how users can specify TCP Keep-Alive + options is now included in the toolbelt. + +- Deprecated functions from ``requests.utils`` are now included and + maintained. + +- An authentication tool that allows users to specify how to authenticate to + several different domains at once is now included. + +- A function to save streamed responses to disk by analyzing the + ``Content-Disposition`` header is now included in the toolbelt. + +Fixed Bugs +~~~~~~~~~~ + +- The ``MultipartEncoder`` will now allow users to upload files larger than + 4GB on 32-bit systems. + +- The ``MultipartEncoder`` will now accept empty unicode strings for form + values. + +0.3.1 -- 2014-06-23 +------------------- + +- Fix the fact that 0.3.0 bundle did not include the ``StreamingIterator`` + +0.3.0 -- 2014-05-21 +------------------- + +Bug Fixes +~~~~~~~~~ + +- Complete rewrite of ``MultipartEncoder`` fixes bug where bytes were lost in + uploads + +New Features +~~~~~~~~~~~~ + +- ``MultipartDecoder`` to accept ``multipart/form-data`` response bodies and + parse them into an easy to use object. + +- ``SourceAddressAdapter`` to allow users to choose a local address to bind + connections to. + +- ``GuessAuth`` which accepts a username and password and uses the + ``WWW-Authenticate`` header to determine how to authenticate against a + server. + +- ``MultipartEncoderMonitor`` wraps an instance of the ``MultipartEncoder`` + and keeps track of how many bytes were read and will call the provided + callback. + +- ``StreamingIterator`` will wrap an iterator and stream the upload instead of + chunk it, provided you also provide the length of the content you wish to + upload. + +0.2.0 -- 2014-02-24 +------------------- + +- Add ability to tell ``MultipartEncoder`` which encoding to use. By default + it uses 'utf-8'. + +- Fix #10 - allow users to install with pip + +- Fix #9 - Fix ``MultipartEncoder#to_string`` so that it properly handles file + objects as fields + +0.1.2 -- 2014-01-19 +------------------- + +- At some point during development we broke how we handle normal file objects. + Thanks to @konomae this is now fixed. + +0.1.1 -- 2014-01-19 +------------------- + +- Handle ``io.BytesIO``-like objects better + +0.1.0 -- 2014-01-18 +------------------- + +- Add initial implementation of the streaming ``MultipartEncoder`` + +- Add initial implementation of the ``user_agent`` function + +- Add the ``SSLAdapter`` diff --git a/requests_toolbelt.egg-info/SOURCES.txt b/requests_toolbelt.egg-info/SOURCES.txt new file mode 100644 index 0000000..3d9c885 --- /dev/null +++ b/requests_toolbelt.egg-info/SOURCES.txt @@ -0,0 +1,104 @@ +AUTHORS.rst +CODE_OF_CONDUCT.rst +HISTORY.rst +LICENSE +MANIFEST.in +README.rst +dev-requirements.txt +setup.cfg +setup.py +tox.ini +docs/Makefile +docs/adapters.rst +docs/authentication.rst +docs/conf.py +docs/contributing.rst +docs/deprecated.rst +docs/downloadutils.rst +docs/dumputils.rst +docs/exceptions.rst +docs/formdata.rst +docs/index.rst +docs/make.bat +docs/sessions.rst +docs/threading.rst +docs/uploading-data.rst +docs/user-agent.rst +docs/user.rst +requests_toolbelt/__init__.py +requests_toolbelt/_compat.py +requests_toolbelt/exceptions.py +requests_toolbelt/sessions.py +requests_toolbelt/streaming_iterator.py +requests_toolbelt.egg-info/PKG-INFO +requests_toolbelt.egg-info/SOURCES.txt +requests_toolbelt.egg-info/dependency_links.txt +requests_toolbelt.egg-info/requires.txt +requests_toolbelt.egg-info/top_level.txt +requests_toolbelt/adapters/__init__.py +requests_toolbelt/adapters/appengine.py +requests_toolbelt/adapters/fingerprint.py +requests_toolbelt/adapters/host_header_ssl.py +requests_toolbelt/adapters/socket_options.py +requests_toolbelt/adapters/source.py +requests_toolbelt/adapters/ssl.py +requests_toolbelt/adapters/x509.py +requests_toolbelt/auth/__init__.py +requests_toolbelt/auth/_digest_auth_compat.py +requests_toolbelt/auth/guess.py +requests_toolbelt/auth/handler.py +requests_toolbelt/auth/http_proxy_digest.py +requests_toolbelt/cookies/__init__.py +requests_toolbelt/cookies/forgetful.py +requests_toolbelt/downloadutils/__init__.py +requests_toolbelt/downloadutils/stream.py +requests_toolbelt/downloadutils/tee.py +requests_toolbelt/multipart/__init__.py +requests_toolbelt/multipart/decoder.py +requests_toolbelt/multipart/encoder.py +requests_toolbelt/threaded/__init__.py +requests_toolbelt/threaded/pool.py +requests_toolbelt/threaded/thread.py +requests_toolbelt/utils/__init__.py +requests_toolbelt/utils/deprecated.py +requests_toolbelt/utils/dump.py +requests_toolbelt/utils/formdata.py +requests_toolbelt/utils/user_agent.py +tests/__init__.py +tests/conftest.py +tests/test_appengine_adapter.py +tests/test_auth.py +tests/test_auth_handler.py +tests/test_downloadutils.py +tests/test_dump.py +tests/test_fingerprintadapter.py +tests/test_forgetfulcookiejar.py +tests/test_formdata.py +tests/test_host_header_ssl_adapter.py +tests/test_multipart_decoder.py +tests/test_multipart_encoder.py +tests/test_multipart_monitor.py +tests/test_proxy_digest_auth.py +tests/test_sessions.py +tests/test_socket_options_adapter.py +tests/test_source_adapter.py +tests/test_ssladapter.py +tests/test_streaming_iterator.py +tests/test_user_agent.py +tests/test_x509_adapter.py +tests/cassettes/file_for_download.json +tests/cassettes/http2bin_cookies.json +tests/cassettes/http2bin_fingerprint.json +tests/cassettes/httpbin_guess_auth_basic.json +tests/cassettes/httpbin_guess_auth_digest.json +tests/cassettes/httpbin_guess_auth_none.json +tests/cassettes/klevas_vu_lt_ssl3.json +tests/cassettes/redirect_request_for_dump_all.json +tests/cassettes/simple_get_request.json +tests/cassettes/stream_response_to_file.json +tests/cassettes/test_x509_adapter_der.json +tests/cassettes/test_x509_adapter_pem.json +tests/threaded/__init__.py +tests/threaded/test_api.py +tests/threaded/test_pool.py +tests/threaded/test_thread.py \ No newline at end of file diff --git a/requests_toolbelt.egg-info/dependency_links.txt b/requests_toolbelt.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/requests_toolbelt.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/requests_toolbelt.egg-info/requires.txt b/requests_toolbelt.egg-info/requires.txt new file mode 100644 index 0000000..a35037c --- /dev/null +++ b/requests_toolbelt.egg-info/requires.txt @@ -0,0 +1 @@ +requests<3.0.0,>=2.0.1 diff --git a/requests_toolbelt.egg-info/top_level.txt b/requests_toolbelt.egg-info/top_level.txt new file mode 100644 index 0000000..976bdfe --- /dev/null +++ b/requests_toolbelt.egg-info/top_level.txt @@ -0,0 +1 @@ +requests_toolbelt diff --git a/requests_toolbelt/__init__.py b/requests_toolbelt/__init__.py new file mode 100644 index 0000000..84e8e58 --- /dev/null +++ b/requests_toolbelt/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +""" +requests-toolbelt +================= + +See https://toolbelt.readthedocs.io/ for documentation + +:copyright: (c) 2014 by Ian Cordasco and Cory Benfield +:license: Apache v2.0, see LICENSE for more details +""" + +from .adapters import SSLAdapter, SourceAddressAdapter +from .auth.guess import GuessAuth +from .multipart import ( + MultipartEncoder, MultipartEncoderMonitor, MultipartDecoder, + ImproperBodyPartContentException, NonMultipartContentTypeException + ) +from .streaming_iterator import StreamingIterator +from .utils.user_agent import user_agent + +__title__ = 'requests-toolbelt' +__authors__ = 'Ian Cordasco, Cory Benfield' +__license__ = 'Apache v2.0' +__copyright__ = 'Copyright 2014 Ian Cordasco, Cory Benfield' +__version__ = '0.10.1' +__version_info__ = tuple(int(i) for i in __version__.split('.')) + +__all__ = [ + 'GuessAuth', 'MultipartEncoder', 'MultipartEncoderMonitor', + 'MultipartDecoder', 'SSLAdapter', 'SourceAddressAdapter', + 'StreamingIterator', 'user_agent', 'ImproperBodyPartContentException', + 'NonMultipartContentTypeException', '__title__', '__authors__', + '__license__', '__copyright__', '__version__', '__version_info__', +] diff --git a/requests_toolbelt/_compat.py b/requests_toolbelt/_compat.py new file mode 100644 index 0000000..e813461 --- /dev/null +++ b/requests_toolbelt/_compat.py @@ -0,0 +1,311 @@ +"""Private module full of compatibility hacks. + +Primarily this is for downstream redistributions of requests that unvendor +urllib3 without providing a shim. + +.. warning:: + + This module is private. If you use it, and something breaks, you were + warned +""" +import sys + +import requests + +try: + from requests.packages.urllib3 import fields + from requests.packages.urllib3 import filepost + from requests.packages.urllib3 import poolmanager +except ImportError: + from urllib3 import fields + from urllib3 import filepost + from urllib3 import poolmanager + +try: + from requests.packages.urllib3.connection import HTTPConnection + from requests.packages.urllib3 import connection +except ImportError: + try: + from urllib3.connection import HTTPConnection + from urllib3 import connection + except ImportError: + HTTPConnection = None + connection = None + + +if requests.__build__ < 0x020300: + timeout = None +else: + try: + from requests.packages.urllib3.util import timeout + except ImportError: + from urllib3.util import timeout + +if requests.__build__ < 0x021000: + gaecontrib = None +else: + try: + from requests.packages.urllib3.contrib import appengine as gaecontrib + except ImportError: + from urllib3.contrib import appengine as gaecontrib + +PY3 = sys.version_info > (3, 0) + +if PY3: + from collections.abc import Mapping, MutableMapping + import queue + from urllib.parse import urlencode, urljoin +else: + from collections import Mapping, MutableMapping + import Queue as queue + from urllib import urlencode + from urlparse import urljoin + +try: + basestring = basestring +except NameError: + basestring = (str, bytes) + + +class HTTPHeaderDict(MutableMapping): + """ + :param headers: + An iterable of field-value pairs. Must not contain multiple field names + when compared case-insensitively. + + :param kwargs: + Additional field-value pairs to pass in to ``dict.update``. + + A ``dict`` like container for storing HTTP Headers. + + Field names are stored and compared case-insensitively in compliance with + RFC 7230. Iteration provides the first case-sensitive key seen for each + case-insensitive pair. + + Using ``__setitem__`` syntax overwrites fields that compare equal + case-insensitively in order to maintain ``dict``'s api. For fields that + compare equal, instead create a new ``HTTPHeaderDict`` and use ``.add`` + in a loop. + + If multiple fields that are equal case-insensitively are passed to the + constructor or ``.update``, the behavior is undefined and some will be + lost. + + >>> headers = HTTPHeaderDict() + >>> headers.add('Set-Cookie', 'foo=bar') + >>> headers.add('set-cookie', 'baz=quxx') + >>> headers['content-length'] = '7' + >>> headers['SET-cookie'] + 'foo=bar, baz=quxx' + >>> headers['Content-Length'] + '7' + """ + + def __init__(self, headers=None, **kwargs): + super(HTTPHeaderDict, self).__init__() + self._container = {} + if headers is not None: + if isinstance(headers, HTTPHeaderDict): + self._copy_from(headers) + else: + self.extend(headers) + if kwargs: + self.extend(kwargs) + + def __setitem__(self, key, val): + self._container[key.lower()] = (key, val) + return self._container[key.lower()] + + def __getitem__(self, key): + val = self._container[key.lower()] + return ', '.join(val[1:]) + + def __delitem__(self, key): + del self._container[key.lower()] + + def __contains__(self, key): + return key.lower() in self._container + + def __eq__(self, other): + if not isinstance(other, Mapping) and not hasattr(other, 'keys'): + return False + if not isinstance(other, type(self)): + other = type(self)(other) + return ({k.lower(): v for k, v in self.itermerged()} == + {k.lower(): v for k, v in other.itermerged()}) + + def __ne__(self, other): + return not self.__eq__(other) + + if not PY3: # Python 2 + iterkeys = MutableMapping.iterkeys + itervalues = MutableMapping.itervalues + + __marker = object() + + def __len__(self): + return len(self._container) + + def __iter__(self): + # Only provide the originally cased names + for vals in self._container.values(): + yield vals[0] + + def pop(self, key, default=__marker): + """D.pop(k[,d]) -> v, remove specified key and return its value. + + If key is not found, d is returned if given, otherwise KeyError is + raised. + """ + # Using the MutableMapping function directly fails due to the private + # marker. + # Using ordinary dict.pop would expose the internal structures. + # So let's reinvent the wheel. + try: + value = self[key] + except KeyError: + if default is self.__marker: + raise + return default + else: + del self[key] + return value + + def discard(self, key): + try: + del self[key] + except KeyError: + pass + + def add(self, key, val): + """Adds a (name, value) pair, doesn't overwrite the value if it already + exists. + + >>> headers = HTTPHeaderDict(foo='bar') + >>> headers.add('Foo', 'baz') + >>> headers['foo'] + 'bar, baz' + """ + key_lower = key.lower() + new_vals = key, val + # Keep the common case aka no item present as fast as possible + vals = self._container.setdefault(key_lower, new_vals) + if new_vals is not vals: + # new_vals was not inserted, as there was a previous one + if isinstance(vals, list): + # If already several items got inserted, we have a list + vals.append(val) + else: + # vals should be a tuple then, i.e. only one item so far + # Need to convert the tuple to list for further extension + self._container[key_lower] = [vals[0], vals[1], val] + + def extend(self, *args, **kwargs): + """Generic import function for any type of header-like object. + Adapted version of MutableMapping.update in order to insert items + with self.add instead of self.__setitem__ + """ + if len(args) > 1: + raise TypeError("extend() takes at most 1 positional " + "arguments ({} given)".format(len(args))) + other = args[0] if len(args) >= 1 else () + + if isinstance(other, HTTPHeaderDict): + for key, val in other.iteritems(): + self.add(key, val) + elif isinstance(other, Mapping): + for key in other: + self.add(key, other[key]) + elif hasattr(other, "keys"): + for key in other.keys(): + self.add(key, other[key]) + else: + for key, value in other: + self.add(key, value) + + for key, value in kwargs.items(): + self.add(key, value) + + def getlist(self, key): + """Returns a list of all the values for the named field. Returns an + empty list if the key doesn't exist.""" + try: + vals = self._container[key.lower()] + except KeyError: + return [] + else: + if isinstance(vals, tuple): + return [vals[1]] + else: + return vals[1:] + + # Backwards compatibility for httplib + getheaders = getlist + getallmatchingheaders = getlist + iget = getlist + + def __repr__(self): + return "%s(%s)" % (type(self).__name__, dict(self.itermerged())) + + def _copy_from(self, other): + for key in other: + val = other.getlist(key) + if isinstance(val, list): + # Don't need to convert tuples + val = list(val) + self._container[key.lower()] = [key] + val + + def copy(self): + clone = type(self)() + clone._copy_from(self) + return clone + + def iteritems(self): + """Iterate over all header lines, including duplicate ones.""" + for key in self: + vals = self._container[key.lower()] + for val in vals[1:]: + yield vals[0], val + + def itermerged(self): + """Iterate over all headers, merging duplicate ones together.""" + for key in self: + val = self._container[key.lower()] + yield val[0], ', '.join(val[1:]) + + def items(self): + return list(self.iteritems()) + + @classmethod + def from_httplib(cls, message): # Python 2 + """Read headers from a Python 2 httplib message object.""" + # python2.7 does not expose a proper API for exporting multiheaders + # efficiently. This function re-reads raw lines from the message + # object and extracts the multiheaders properly. + headers = [] + + for line in message.headers: + if line.startswith((' ', '\t')): + key, value = headers[-1] + headers[-1] = (key, value + '\r\n' + line.rstrip()) + continue + + key, value = line.split(':', 1) + headers.append((key, value.strip())) + + return cls(headers) + + +__all__ = ( + 'basestring', + 'connection', + 'fields', + 'filepost', + 'poolmanager', + 'timeout', + 'HTTPHeaderDict', + 'queue', + 'urlencode', + 'gaecontrib', + 'urljoin', +) diff --git a/requests_toolbelt/adapters/__init__.py b/requests_toolbelt/adapters/__init__.py new file mode 100644 index 0000000..7195f43 --- /dev/null +++ b/requests_toolbelt/adapters/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" +requests-toolbelt.adapters +========================== + +See https://toolbelt.readthedocs.io/ for documentation + +:copyright: (c) 2014 by Ian Cordasco and Cory Benfield +:license: Apache v2.0, see LICENSE for more details +""" + +from .ssl import SSLAdapter +from .source import SourceAddressAdapter + +__all__ = ['SSLAdapter', 'SourceAddressAdapter'] diff --git a/requests_toolbelt/adapters/appengine.py b/requests_toolbelt/adapters/appengine.py new file mode 100644 index 0000000..25a70a1 --- /dev/null +++ b/requests_toolbelt/adapters/appengine.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +"""The App Engine Transport Adapter for requests. + +.. versionadded:: 0.6.0 + +This requires a version of requests >= 2.10.0 and Python 2. + +There are two ways to use this library: + +#. If you're using requests directly, you can use code like: + + .. code-block:: python + + >>> import requests + >>> import ssl + >>> import requests.packages.urllib3.contrib.appengine as ul_appengine + >>> from requests_toolbelt.adapters import appengine + >>> s = requests.Session() + >>> if ul_appengine.is_appengine_sandbox(): + ... s.mount('http://', appengine.AppEngineAdapter()) + ... s.mount('https://', appengine.AppEngineAdapter()) + +#. If you depend on external libraries which use requests, you can use code + like: + + .. code-block:: python + + >>> from requests_toolbelt.adapters import appengine + >>> appengine.monkeypatch() + +which will ensure all requests.Session objects use AppEngineAdapter properly. + +You are also able to :ref:`disable certificate validation ` +when monkey-patching. +""" +import requests +import warnings +from requests import adapters +from requests import sessions + +from .. import exceptions as exc +from .._compat import gaecontrib +from .._compat import timeout + + +class AppEngineMROHack(adapters.HTTPAdapter): + """Resolves infinite recursion when monkeypatching. + + This works by injecting itself as the base class of both the + :class:`AppEngineAdapter` and Requests' default HTTPAdapter, which needs to + be done because default HTTPAdapter's MRO is recompiled when we + monkeypatch, at which point this class becomes HTTPAdapter's base class. + In addition, we use an instantiation flag to avoid infinite recursion. + """ + _initialized = False + + def __init__(self, *args, **kwargs): + if not self._initialized: + self._initialized = True + super(AppEngineMROHack, self).__init__(*args, **kwargs) + + +class AppEngineAdapter(AppEngineMROHack, adapters.HTTPAdapter): + """The transport adapter for Requests to use urllib3's GAE support. + + Implements Requests's HTTPAdapter API. + + When deploying to Google's App Engine service, some of Requests' + functionality is broken. There is underlying support for GAE in urllib3. + This functionality, however, is opt-in and needs to be enabled explicitly + for Requests to be able to use it. + """ + + __attrs__ = adapters.HTTPAdapter.__attrs__ + ['_validate_certificate'] + + def __init__(self, validate_certificate=True, *args, **kwargs): + _check_version() + self._validate_certificate = validate_certificate + super(AppEngineAdapter, self).__init__(*args, **kwargs) + + def init_poolmanager(self, connections, maxsize, block=False): + self.poolmanager = _AppEnginePoolManager(self._validate_certificate) + + +class InsecureAppEngineAdapter(AppEngineAdapter): + """An always-insecure GAE adapter for Requests. + + This is a variant of the the transport adapter for Requests to use + urllib3's GAE support that does not validate certificates. Use with + caution! + + .. note:: + The ``validate_certificate`` keyword argument will not be honored here + and is not part of the signature because we always force it to + ``False``. + + See :class:`AppEngineAdapter` for further details. + """ + + def __init__(self, *args, **kwargs): + if kwargs.pop("validate_certificate", False): + warnings.warn("Certificate validation cannot be specified on the " + "InsecureAppEngineAdapter, but was present. This " + "will be ignored and certificate validation will " + "remain off.", exc.IgnoringGAECertificateValidation) + + super(InsecureAppEngineAdapter, self).__init__( + validate_certificate=False, *args, **kwargs) + + +class _AppEnginePoolManager(object): + """Implements urllib3's PoolManager API expected by requests. + + While a real PoolManager map hostnames to reusable Connections, + AppEngine has no concept of a reusable connection to a host. + So instead, this class constructs a small Connection per request, + that is returned to the Adapter and used to access the URL. + """ + + def __init__(self, validate_certificate=True): + self.appengine_manager = gaecontrib.AppEngineManager( + validate_certificate=validate_certificate) + + def connection_from_url(self, url): + return _AppEngineConnection(self.appengine_manager, url) + + def clear(self): + pass + + +class _AppEngineConnection(object): + """Implements urllib3's HTTPConnectionPool API's urlopen(). + + This Connection's urlopen() is called with a host-relative path, + so in order to properly support opening the URL, we need to store + the full URL when this Connection is constructed from the PoolManager. + + This code wraps AppEngineManager.urlopen(), which exposes a different + API than in the original urllib3 urlopen(), and thus needs this adapter. + """ + + def __init__(self, appengine_manager, url): + self.appengine_manager = appengine_manager + self.url = url + + def urlopen(self, method, url, body=None, headers=None, retries=None, + redirect=True, assert_same_host=True, + timeout=timeout.Timeout.DEFAULT_TIMEOUT, + pool_timeout=None, release_conn=None, **response_kw): + # This function's url argument is a host-relative URL, + # but the AppEngineManager expects an absolute URL. + # So we saved out the self.url when the AppEngineConnection + # was constructed, which we then can use down below instead. + + # We once tried to verify our assumptions here, but sometimes the + # passed-in URL differs on url fragments, or "http://a.com" vs "/". + + # urllib3's App Engine adapter only uses Timeout.total, not read or + # connect. + if not timeout.total: + timeout.total = timeout._read or timeout._connect + + # Jump through the hoops necessary to call AppEngineManager's API. + return self.appengine_manager.urlopen( + method, + self.url, + body=body, + headers=headers, + retries=retries, + redirect=redirect, + timeout=timeout, + **response_kw) + + +def monkeypatch(validate_certificate=True): + """Sets up all Sessions to use AppEngineAdapter by default. + + If you don't want to deal with configuring your own Sessions, + or if you use libraries that use requests directly (ie requests.post), + then you may prefer to monkeypatch and auto-configure all Sessions. + + .. warning: : + + If ``validate_certificate`` is ``False``, certification validation will + effectively be disabled for all requests. + """ + _check_version() + # HACK: We should consider modifying urllib3 to support this cleanly, + # so that we can set a module-level variable in the sessions module, + # instead of overriding an imported HTTPAdapter as is done here. + adapter = AppEngineAdapter + if not validate_certificate: + adapter = InsecureAppEngineAdapter + + sessions.HTTPAdapter = adapter + adapters.HTTPAdapter = adapter + + +def _check_version(): + if gaecontrib is None: + raise exc.VersionMismatchError( + "The toolbelt requires at least Requests 2.10.0 to be " + "installed. Version {} was found instead.".format( + requests.__version__ + ) + ) diff --git a/requests_toolbelt/adapters/fingerprint.py b/requests_toolbelt/adapters/fingerprint.py new file mode 100644 index 0000000..6645d34 --- /dev/null +++ b/requests_toolbelt/adapters/fingerprint.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +"""Submodule containing the implementation for the FingerprintAdapter. + +This file contains an implementation of a Transport Adapter that validates +the fingerprints of SSL certificates presented upon connection. +""" +from requests.adapters import HTTPAdapter + +from .._compat import poolmanager + + +class FingerprintAdapter(HTTPAdapter): + """ + A HTTPS Adapter for Python Requests that verifies certificate fingerprints, + instead of certificate hostnames. + + Example usage: + + .. code-block:: python + + import requests + import ssl + from requests_toolbelt.adapters.fingerprint import FingerprintAdapter + + twitter_fingerprint = '...' + s = requests.Session() + s.mount( + 'https://twitter.com', + FingerprintAdapter(twitter_fingerprint) + ) + + The fingerprint should be provided as a hexadecimal string, optionally + containing colons. + """ + + __attrs__ = HTTPAdapter.__attrs__ + ['fingerprint'] + + def __init__(self, fingerprint, **kwargs): + self.fingerprint = fingerprint + + super(FingerprintAdapter, self).__init__(**kwargs) + + def init_poolmanager(self, connections, maxsize, block=False): + self.poolmanager = poolmanager.PoolManager( + num_pools=connections, + maxsize=maxsize, + block=block, + assert_fingerprint=self.fingerprint) diff --git a/requests_toolbelt/adapters/host_header_ssl.py b/requests_toolbelt/adapters/host_header_ssl.py new file mode 100644 index 0000000..f34ed1a --- /dev/null +++ b/requests_toolbelt/adapters/host_header_ssl.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +""" +requests_toolbelt.adapters.host_header_ssl +========================================== + +This file contains an implementation of the HostHeaderSSLAdapter. +""" + +from requests.adapters import HTTPAdapter + + +class HostHeaderSSLAdapter(HTTPAdapter): + """ + A HTTPS Adapter for Python Requests that sets the hostname for certificate + verification based on the Host header. + + This allows requesting the IP address directly via HTTPS without getting + a "hostname doesn't match" exception. + + Example usage: + + >>> s.mount('https://', HostHeaderSSLAdapter()) + >>> s.get("https://93.184.216.34", headers={"Host": "example.org"}) + + """ + + def send(self, request, **kwargs): + # HTTP headers are case-insensitive (RFC 7230) + host_header = None + for header in request.headers: + if header.lower() == "host": + host_header = request.headers[header] + break + + connection_pool_kwargs = self.poolmanager.connection_pool_kw + + if host_header: + connection_pool_kwargs["assert_hostname"] = host_header + elif "assert_hostname" in connection_pool_kwargs: + # an assert_hostname from a previous request may have been left + connection_pool_kwargs.pop("assert_hostname", None) + + return super(HostHeaderSSLAdapter, self).send(request, **kwargs) diff --git a/requests_toolbelt/adapters/socket_options.py b/requests_toolbelt/adapters/socket_options.py new file mode 100644 index 0000000..86ebe13 --- /dev/null +++ b/requests_toolbelt/adapters/socket_options.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +"""The implementation of the SocketOptionsAdapter.""" +import socket +import warnings +import sys + +import requests +from requests import adapters + +from .._compat import connection +from .._compat import poolmanager +from .. import exceptions as exc + + +class SocketOptionsAdapter(adapters.HTTPAdapter): + """An adapter for requests that allows users to specify socket options. + + Since version 2.4.0 of requests, it is possible to specify a custom list + of socket options that need to be set before establishing the connection. + + Example usage:: + + >>> import socket + >>> import requests + >>> from requests_toolbelt.adapters import socket_options + >>> s = requests.Session() + >>> opts = [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0)] + >>> adapter = socket_options.SocketOptionsAdapter(socket_options=opts) + >>> s.mount('http://', adapter) + + You can also take advantage of the list of default options on this class + to keep using the original options in addition to your custom options. In + that case, ``opts`` might look like:: + + >>> opts = socket_options.SocketOptionsAdapter.default_options + opts + + """ + + if connection is not None: + default_options = getattr( + connection.HTTPConnection, + 'default_socket_options', + [(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)] + ) + else: + default_options = [] + warnings.warn(exc.RequestsVersionTooOld, + "This version of Requests is only compatible with a " + "version of urllib3 which is too old to support " + "setting options on a socket. This adapter is " + "functionally useless.") + + def __init__(self, **kwargs): + self.socket_options = kwargs.pop('socket_options', + self.default_options) + + super(SocketOptionsAdapter, self).__init__(**kwargs) + + def init_poolmanager(self, connections, maxsize, block=False): + if requests.__build__ >= 0x020400: + # NOTE(Ian): Perhaps we should raise a warning + self.poolmanager = poolmanager.PoolManager( + num_pools=connections, + maxsize=maxsize, + block=block, + socket_options=self.socket_options + ) + else: + super(SocketOptionsAdapter, self).init_poolmanager( + connections, maxsize, block + ) + + +class TCPKeepAliveAdapter(SocketOptionsAdapter): + """An adapter for requests that turns on TCP Keep-Alive by default. + + The adapter sets 4 socket options: + + - ``SOL_SOCKET`` ``SO_KEEPALIVE`` - This turns on TCP Keep-Alive + - ``IPPROTO_TCP`` ``TCP_KEEPINTVL`` 20 - Sets the keep alive interval + - ``IPPROTO_TCP`` ``TCP_KEEPCNT`` 5 - Sets the number of keep alive probes + - ``IPPROTO_TCP`` ``TCP_KEEPIDLE`` 60 - Sets the keep alive time if the + socket library has the ``TCP_KEEPIDLE`` constant + + The latter three can be overridden by keyword arguments (respectively): + + - ``interval`` + - ``count`` + - ``idle`` + + You can use this adapter like so:: + + >>> from requests_toolbelt.adapters import socket_options + >>> tcp = socket_options.TCPKeepAliveAdapter(idle=120, interval=10) + >>> s = requests.Session() + >>> s.mount('http://', tcp) + + """ + + def __init__(self, **kwargs): + socket_options = kwargs.pop('socket_options', + SocketOptionsAdapter.default_options) + idle = kwargs.pop('idle', 60) + interval = kwargs.pop('interval', 20) + count = kwargs.pop('count', 5) + socket_options = socket_options + [ + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + ] + + # NOTE(Ian): OSX does not have these constants defined, so we + # set them conditionally. + if getattr(socket, 'TCP_KEEPINTVL', None) is not None: + socket_options += [(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, + interval)] + elif sys.platform == 'darwin': + # On OSX, TCP_KEEPALIVE from netinet/tcp.h is not exported + # by python's socket module + TCP_KEEPALIVE = getattr(socket, 'TCP_KEEPALIVE', 0x10) + socket_options += [(socket.IPPROTO_TCP, TCP_KEEPALIVE, interval)] + + if getattr(socket, 'TCP_KEEPCNT', None) is not None: + socket_options += [(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, count)] + + if getattr(socket, 'TCP_KEEPIDLE', None) is not None: + socket_options += [(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, idle)] + + super(TCPKeepAliveAdapter, self).__init__( + socket_options=socket_options, **kwargs + ) diff --git a/requests_toolbelt/adapters/source.py b/requests_toolbelt/adapters/source.py new file mode 100644 index 0000000..d3dda79 --- /dev/null +++ b/requests_toolbelt/adapters/source.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +""" +requests_toolbelt.source_adapter +================================ + +This file contains an implementation of the SourceAddressAdapter originally +demonstrated on the Requests GitHub page. +""" +from requests.adapters import HTTPAdapter + +from .._compat import poolmanager, basestring + + +class SourceAddressAdapter(HTTPAdapter): + """ + A Source Address Adapter for Python Requests that enables you to choose the + local address to bind to. This allows you to send your HTTP requests from a + specific interface and IP address. + + Two address formats are accepted. The first is a string: this will set the + local IP address to the address given in the string, and will also choose a + semi-random high port for the local port number. + + The second is a two-tuple of the form (ip address, port): for example, + ``('10.10.10.10', 8999)``. This will set the local IP address to the first + element, and the local port to the second element. If ``0`` is used as the + port number, a semi-random high port will be selected. + + .. warning:: Setting an explicit local port can have negative interactions + with connection-pooling in Requests: in particular, it risks + the possibility of getting "Address in use" errors. The + string-only argument is generally preferred to the tuple-form. + + Example usage: + + .. code-block:: python + + import requests + from requests_toolbelt.adapters.source import SourceAddressAdapter + + s = requests.Session() + s.mount('http://', SourceAddressAdapter('10.10.10.10')) + s.mount('https://', SourceAddressAdapter(('10.10.10.10', 8999))) + """ + def __init__(self, source_address, **kwargs): + if isinstance(source_address, basestring): + self.source_address = (source_address, 0) + elif isinstance(source_address, tuple): + self.source_address = source_address + else: + raise TypeError( + "source_address must be IP address string or (ip, port) tuple" + ) + + super(SourceAddressAdapter, self).__init__(**kwargs) + + def init_poolmanager(self, connections, maxsize, block=False): + self.poolmanager = poolmanager.PoolManager( + num_pools=connections, + maxsize=maxsize, + block=block, + source_address=self.source_address) + + def proxy_manager_for(self, *args, **kwargs): + kwargs['source_address'] = self.source_address + return super(SourceAddressAdapter, self).proxy_manager_for( + *args, **kwargs) diff --git a/requests_toolbelt/adapters/ssl.py b/requests_toolbelt/adapters/ssl.py new file mode 100644 index 0000000..c4a76ae --- /dev/null +++ b/requests_toolbelt/adapters/ssl.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +""" + +requests_toolbelt.ssl_adapter +============================= + +This file contains an implementation of the SSLAdapter originally demonstrated +in this blog post: +https://lukasa.co.uk/2013/01/Choosing_SSL_Version_In_Requests/ + +""" +import requests + +from requests.adapters import HTTPAdapter + +from .._compat import poolmanager + + +class SSLAdapter(HTTPAdapter): + """ + A HTTPS Adapter for Python Requests that allows the choice of the SSL/TLS + version negotiated by Requests. This can be used either to enforce the + choice of high-security TLS versions (where supported), or to work around + misbehaving servers that fail to correctly negotiate the default TLS + version being offered. + + Example usage: + + >>> import requests + >>> import ssl + >>> from requests_toolbelt import SSLAdapter + >>> s = requests.Session() + >>> s.mount('https://', SSLAdapter(ssl.PROTOCOL_TLSv1)) + + You can replace the chosen protocol with any that are available in the + default Python SSL module. All subsequent requests that match the adapter + prefix will use the chosen SSL version instead of the default. + + This adapter will also attempt to change the SSL/TLS version negotiated by + Requests when using a proxy. However, this may not always be possible: + prior to Requests v2.4.0 the adapter did not have access to the proxy setup + code. In earlier versions of Requests, this adapter will not function + properly when used with proxies. + """ + + __attrs__ = HTTPAdapter.__attrs__ + ['ssl_version'] + + def __init__(self, ssl_version=None, **kwargs): + self.ssl_version = ssl_version + + super(SSLAdapter, self).__init__(**kwargs) + + def init_poolmanager(self, connections, maxsize, block=False): + self.poolmanager = poolmanager.PoolManager( + num_pools=connections, + maxsize=maxsize, + block=block, + ssl_version=self.ssl_version) + + if requests.__build__ >= 0x020400: + # Earlier versions of requests either don't have this method or, worse, + # don't allow passing arbitrary keyword arguments. As a result, only + # conditionally define this method. + def proxy_manager_for(self, *args, **kwargs): + kwargs['ssl_version'] = self.ssl_version + return super(SSLAdapter, self).proxy_manager_for(*args, **kwargs) diff --git a/requests_toolbelt/adapters/x509.py b/requests_toolbelt/adapters/x509.py new file mode 100644 index 0000000..aff3770 --- /dev/null +++ b/requests_toolbelt/adapters/x509.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +"""A X509Adapter for use with the requests library. + +This file contains an implementation of the X509Adapter that will +allow users to authenticate a request using an arbitrary +X.509 certificate without needing to convert it to a .pem file + +""" + +from OpenSSL.crypto import PKey, X509 +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import (load_pem_private_key, + load_der_private_key) +from cryptography.hazmat.primitives.serialization import Encoding +from cryptography.hazmat.backends import default_backend + +from datetime import datetime +from requests.adapters import HTTPAdapter +import requests + +from .. import exceptions as exc + +""" +importing the protocol constants from _ssl instead of ssl because only the +constants are needed and to handle issues caused by importing from ssl on +the 2.7.x line. +""" +try: + from _ssl import PROTOCOL_TLS as PROTOCOL +except ImportError: + from _ssl import PROTOCOL_SSLv23 as PROTOCOL + + +PyOpenSSLContext = None + + +class X509Adapter(HTTPAdapter): + r"""Adapter for use with X.509 certificates. + + Provides an interface for Requests sessions to contact HTTPS urls and + authenticate with an X.509 cert by implementing the Transport Adapter + interface. This class will need to be manually instantiated and mounted + to the session + + :param pool_connections: The number of urllib3 connection pools to + cache. + :param pool_maxsize: The maximum number of connections to save in the + pool. + :param max_retries: The maximum number of retries each connection + should attempt. Note, this applies only to failed DNS lookups, + socket connections and connection timeouts, never to requests where + data has made it to the server. By default, Requests does not retry + failed connections. If you need granular control over the + conditions under which we retry a request, import urllib3's + ``Retry`` class and pass that instead. + :param pool_block: Whether the connection pool should block for + connections. + + :param bytes cert_bytes: + bytes object containing contents of a cryptography.x509Certificate + object using the encoding specified by the ``encoding`` parameter. + :param bytes pk_bytes: + bytes object containing contents of a object that implements + ``cryptography.hazmat.primitives.serialization.PrivateFormat`` + using the encoding specified by the ``encoding`` parameter. + :param password: + string or utf8 encoded bytes containing the passphrase used for the + private key. None if unencrypted. Defaults to None. + :param encoding: + Enumeration detailing the encoding method used on the ``cert_bytes`` + parameter. Can be either PEM or DER. Defaults to PEM. + :type encoding: + :class: `cryptography.hazmat.primitives.serialization.Encoding` + + Usage:: + + >>> import requests + >>> from requests_toolbelt.adapters.x509 import X509Adapter + >>> s = requests.Session() + >>> a = X509Adapter(max_retries=3, + cert_bytes=b'...', pk_bytes=b'...', encoding='...' + >>> s.mount('https://', a) + """ + + def __init__(self, *args, **kwargs): + self._import_pyopensslcontext() + self._check_version() + cert_bytes = kwargs.pop('cert_bytes', None) + pk_bytes = kwargs.pop('pk_bytes', None) + password = kwargs.pop('password', None) + encoding = kwargs.pop('encoding', Encoding.PEM) + + password_bytes = None + + if cert_bytes is None or not isinstance(cert_bytes, bytes): + raise ValueError('Invalid cert content provided. ' + 'You must provide an X.509 cert ' + 'formatted as a byte array.') + if pk_bytes is None or not isinstance(pk_bytes, bytes): + raise ValueError('Invalid private key content provided. ' + 'You must provide a private key ' + 'formatted as a byte array.') + + if isinstance(password, bytes): + password_bytes = password + elif password: + password_bytes = password.encode('utf8') + + self.ssl_context = create_ssl_context(cert_bytes, pk_bytes, + password_bytes, encoding) + + super(X509Adapter, self).__init__(*args, **kwargs) + + def init_poolmanager(self, *args, **kwargs): + if self.ssl_context: + kwargs['ssl_context'] = self.ssl_context + return super(X509Adapter, self).init_poolmanager(*args, **kwargs) + + def proxy_manager_for(self, *args, **kwargs): + if self.ssl_context: + kwargs['ssl_context'] = self.ssl_context + return super(X509Adapter, self).proxy_manager_for(*args, **kwargs) + + def _import_pyopensslcontext(self): + global PyOpenSSLContext + + if requests.__build__ < 0x021200: + PyOpenSSLContext = None + else: + try: + from requests.packages.urllib3.contrib.pyopenssl \ + import PyOpenSSLContext + except ImportError: + try: + from urllib3.contrib.pyopenssl import PyOpenSSLContext + except ImportError: + PyOpenSSLContext = None + + def _check_version(self): + if PyOpenSSLContext is None: + raise exc.VersionMismatchError( + "The X509Adapter requires at least Requests 2.12.0 to be " + "installed. Version {} was found instead.".format( + requests.__version__ + ) + ) + + +def check_cert_dates(cert): + """Verify that the supplied client cert is not invalid.""" + + now = datetime.utcnow() + if cert.not_valid_after < now or cert.not_valid_before > now: + raise ValueError('Client certificate expired: Not After: ' + '{:%Y-%m-%d %H:%M:%SZ} ' + 'Not Before: {:%Y-%m-%d %H:%M:%SZ}' + .format(cert.not_valid_after, cert.not_valid_before)) + + +def create_ssl_context(cert_byes, pk_bytes, password=None, + encoding=Encoding.PEM): + """Create an SSL Context with the supplied cert/password. + + :param cert_bytes array of bytes containing the cert encoded + using the method supplied in the ``encoding`` parameter + :param pk_bytes array of bytes containing the private key encoded + using the method supplied in the ``encoding`` parameter + :param password array of bytes containing the passphrase to be used + with the supplied private key. None if unencrypted. + Defaults to None. + :param encoding ``cryptography.hazmat.primitives.serialization.Encoding`` + details the encoding method used on the ``cert_bytes`` and + ``pk_bytes`` parameters. Can be either PEM or DER. + Defaults to PEM. + """ + backend = default_backend() + + cert = None + key = None + if encoding == Encoding.PEM: + cert = x509.load_pem_x509_certificate(cert_byes, backend) + key = load_pem_private_key(pk_bytes, password, backend) + elif encoding == Encoding.DER: + cert = x509.load_der_x509_certificate(cert_byes, backend) + key = load_der_private_key(pk_bytes, password, backend) + else: + raise ValueError('Invalid encoding provided: Must be PEM or DER') + + if not (cert and key): + raise ValueError('Cert and key could not be parsed from ' + 'provided data') + check_cert_dates(cert) + ssl_context = PyOpenSSLContext(PROTOCOL) + ssl_context._ctx.use_certificate(X509.from_cryptography(cert)) + ssl_context._ctx.use_privatekey(PKey.from_cryptography_key(key)) + return ssl_context diff --git a/requests_toolbelt/auth/__init__.py b/requests_toolbelt/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requests_toolbelt/auth/_digest_auth_compat.py b/requests_toolbelt/auth/_digest_auth_compat.py new file mode 100644 index 0000000..285a6a7 --- /dev/null +++ b/requests_toolbelt/auth/_digest_auth_compat.py @@ -0,0 +1,29 @@ +"""Provide a compatibility layer for requests.auth.HTTPDigestAuth.""" +import requests + + +class _ThreadingDescriptor(object): + def __init__(self, prop, default): + self.prop = prop + self.default = default + + def __get__(self, obj, objtype=None): + return getattr(obj._thread_local, self.prop, self.default) + + def __set__(self, obj, value): + setattr(obj._thread_local, self.prop, value) + + +class _HTTPDigestAuth(requests.auth.HTTPDigestAuth): + init = _ThreadingDescriptor('init', True) + last_nonce = _ThreadingDescriptor('last_nonce', '') + nonce_count = _ThreadingDescriptor('nonce_count', 0) + chal = _ThreadingDescriptor('chal', {}) + pos = _ThreadingDescriptor('pos', None) + num_401_calls = _ThreadingDescriptor('num_401_calls', 1) + + +if requests.__build__ < 0x020800: + HTTPDigestAuth = requests.auth.HTTPDigestAuth +else: + HTTPDigestAuth = _HTTPDigestAuth diff --git a/requests_toolbelt/auth/guess.py b/requests_toolbelt/auth/guess.py new file mode 100644 index 0000000..ba6de50 --- /dev/null +++ b/requests_toolbelt/auth/guess.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +"""The module containing the code for GuessAuth.""" +from requests import auth +from requests import cookies + +from . import _digest_auth_compat as auth_compat, http_proxy_digest + + +class GuessAuth(auth.AuthBase): + """Guesses the auth type by the WWW-Authentication header.""" + def __init__(self, username, password): + self.username = username + self.password = password + self.auth = None + self.pos = None + + def _handle_basic_auth_401(self, r, kwargs): + if self.pos is not None: + r.request.body.seek(self.pos) + + # Consume content and release the original connection + # to allow our new request to reuse the same one. + r.content + r.raw.release_conn() + prep = r.request.copy() + if not hasattr(prep, '_cookies'): + prep._cookies = cookies.RequestsCookieJar() + cookies.extract_cookies_to_jar(prep._cookies, r.request, r.raw) + prep.prepare_cookies(prep._cookies) + + self.auth = auth.HTTPBasicAuth(self.username, self.password) + prep = self.auth(prep) + _r = r.connection.send(prep, **kwargs) + _r.history.append(r) + _r.request = prep + + return _r + + def _handle_digest_auth_401(self, r, kwargs): + self.auth = auth_compat.HTTPDigestAuth(self.username, self.password) + try: + self.auth.init_per_thread_state() + except AttributeError: + # If we're not on requests 2.8.0+ this method does not exist and + # is not relevant. + pass + + # Check that the attr exists because much older versions of requests + # set this attribute lazily. For example: + # https://github.com/kennethreitz/requests/blob/33735480f77891754304e7f13e3cdf83aaaa76aa/requests/auth.py#L59 + if (hasattr(self.auth, 'num_401_calls') and + self.auth.num_401_calls is None): + self.auth.num_401_calls = 1 + # Digest auth would resend the request by itself. We can take a + # shortcut here. + return self.auth.handle_401(r, **kwargs) + + def handle_401(self, r, **kwargs): + """Resends a request with auth headers, if needed.""" + + www_authenticate = r.headers.get('www-authenticate', '').lower() + + if 'basic' in www_authenticate: + return self._handle_basic_auth_401(r, kwargs) + + if 'digest' in www_authenticate: + return self._handle_digest_auth_401(r, kwargs) + + def __call__(self, request): + if self.auth is not None: + return self.auth(request) + + try: + self.pos = request.body.tell() + except AttributeError: + pass + + request.register_hook('response', self.handle_401) + return request + + +class GuessProxyAuth(GuessAuth): + """ + Guesses the auth type by WWW-Authentication and Proxy-Authentication + headers + """ + def __init__(self, username=None, password=None, + proxy_username=None, proxy_password=None): + super(GuessProxyAuth, self).__init__(username, password) + self.proxy_username = proxy_username + self.proxy_password = proxy_password + self.proxy_auth = None + + def _handle_basic_auth_407(self, r, kwargs): + if self.pos is not None: + r.request.body.seek(self.pos) + + r.content + r.raw.release_conn() + prep = r.request.copy() + if not hasattr(prep, '_cookies'): + prep._cookies = cookies.RequestsCookieJar() + cookies.extract_cookies_to_jar(prep._cookies, r.request, r.raw) + prep.prepare_cookies(prep._cookies) + + self.proxy_auth = auth.HTTPProxyAuth(self.proxy_username, + self.proxy_password) + prep = self.proxy_auth(prep) + _r = r.connection.send(prep, **kwargs) + _r.history.append(r) + _r.request = prep + + return _r + + def _handle_digest_auth_407(self, r, kwargs): + self.proxy_auth = http_proxy_digest.HTTPProxyDigestAuth( + username=self.proxy_username, + password=self.proxy_password) + + try: + self.auth.init_per_thread_state() + except AttributeError: + pass + + return self.proxy_auth.handle_407(r, **kwargs) + + def handle_407(self, r, **kwargs): + proxy_authenticate = r.headers.get('Proxy-Authenticate', '').lower() + + if 'basic' in proxy_authenticate: + return self._handle_basic_auth_407(r, kwargs) + + if 'digest' in proxy_authenticate: + return self._handle_digest_auth_407(r, kwargs) + + def __call__(self, request): + if self.proxy_auth is not None: + request = self.proxy_auth(request) + + try: + self.pos = request.body.tell() + except AttributeError: + pass + + request.register_hook('response', self.handle_407) + return super(GuessProxyAuth, self).__call__(request) diff --git a/requests_toolbelt/auth/handler.py b/requests_toolbelt/auth/handler.py new file mode 100644 index 0000000..0b4051a --- /dev/null +++ b/requests_toolbelt/auth/handler.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +""" + +requests_toolbelt.auth.handler +============================== + +This holds all of the implementation details of the Authentication Handler. + +""" + +from requests.auth import AuthBase, HTTPBasicAuth +from requests.compat import urlparse, urlunparse + + +class AuthHandler(AuthBase): + + """ + + The ``AuthHandler`` object takes a dictionary of domains paired with + authentication strategies and will use this to determine which credentials + to use when making a request. For example, you could do the following: + + .. code-block:: python + + from requests import HTTPDigestAuth + from requests_toolbelt.auth.handler import AuthHandler + + import requests + + auth = AuthHandler({ + 'https://api.github.com': ('sigmavirus24', 'fakepassword'), + 'https://example.com': HTTPDigestAuth('username', 'password') + }) + + r = requests.get('https://api.github.com/user', auth=auth) + # => + r = requests.get('https://example.com/some/path', auth=auth) + # => + + s = requests.Session() + s.auth = auth + r = s.get('https://api.github.com/user') + # => + + .. warning:: + + :class:`requests.auth.HTTPDigestAuth` is not yet thread-safe. If you + use :class:`AuthHandler` across multiple threads you should + instantiate a new AuthHandler for each thread with a new + HTTPDigestAuth instance for each thread. + + """ + + def __init__(self, strategies): + self.strategies = dict(strategies) + self._make_uniform() + + def __call__(self, request): + auth = self.get_strategy_for(request.url) + return auth(request) + + def __repr__(self): + return ''.format(self.strategies) + + def _make_uniform(self): + existing_strategies = list(self.strategies.items()) + self.strategies = {} + + for (k, v) in existing_strategies: + self.add_strategy(k, v) + + @staticmethod + def _key_from_url(url): + parsed = urlparse(url) + return urlunparse((parsed.scheme.lower(), + parsed.netloc.lower(), + '', '', '', '')) + + def add_strategy(self, domain, strategy): + """Add a new domain and authentication strategy. + + :param str domain: The domain you wish to match against. For example: + ``'https://api.github.com'`` + :param str strategy: The authentication strategy you wish to use for + that domain. For example: ``('username', 'password')`` or + ``requests.HTTPDigestAuth('username', 'password')`` + + .. code-block:: python + + a = AuthHandler({}) + a.add_strategy('https://api.github.com', ('username', 'password')) + + """ + # Turn tuples into Basic Authentication objects + if isinstance(strategy, tuple): + strategy = HTTPBasicAuth(*strategy) + + key = self._key_from_url(domain) + self.strategies[key] = strategy + + def get_strategy_for(self, url): + """Retrieve the authentication strategy for a specified URL. + + :param str url: The full URL you will be making a request against. For + example, ``'https://api.github.com/user'`` + :returns: Callable that adds authentication to a request. + + .. code-block:: python + + import requests + a = AuthHandler({'example.com', ('foo', 'bar')}) + strategy = a.get_strategy_for('http://example.com/example') + assert isinstance(strategy, requests.auth.HTTPBasicAuth) + + """ + key = self._key_from_url(url) + return self.strategies.get(key, NullAuthStrategy()) + + def remove_strategy(self, domain): + """Remove the domain and strategy from the collection of strategies. + + :param str domain: The domain you wish remove. For example, + ``'https://api.github.com'``. + + .. code-block:: python + + a = AuthHandler({'example.com', ('foo', 'bar')}) + a.remove_strategy('example.com') + assert a.strategies == {} + + """ + key = self._key_from_url(domain) + if key in self.strategies: + del self.strategies[key] + + +class NullAuthStrategy(AuthBase): + def __repr__(self): + return '' + + def __call__(self, r): + return r diff --git a/requests_toolbelt/auth/http_proxy_digest.py b/requests_toolbelt/auth/http_proxy_digest.py new file mode 100644 index 0000000..7e1f69e --- /dev/null +++ b/requests_toolbelt/auth/http_proxy_digest.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""The module containing HTTPProxyDigestAuth.""" +import re + +from requests import cookies, utils + +from . import _digest_auth_compat as auth + + +class HTTPProxyDigestAuth(auth.HTTPDigestAuth): + """HTTP digest authentication between proxy + + :param stale_rejects: The number of rejects indicate that: + the client may wish to simply retry the request + with a new encrypted response, without reprompting the user for a + new username and password. i.e., retry build_digest_header + :type stale_rejects: int + """ + _pat = re.compile(r'digest ', flags=re.IGNORECASE) + + def __init__(self, *args, **kwargs): + super(HTTPProxyDigestAuth, self).__init__(*args, **kwargs) + self.stale_rejects = 0 + + self.init_per_thread_state() + + @property + def stale_rejects(self): + thread_local = getattr(self, '_thread_local', None) + if thread_local is None: + return self._stale_rejects + return thread_local.stale_rejects + + @stale_rejects.setter + def stale_rejects(self, value): + thread_local = getattr(self, '_thread_local', None) + if thread_local is None: + self._stale_rejects = value + else: + thread_local.stale_rejects = value + + def init_per_thread_state(self): + try: + super(HTTPProxyDigestAuth, self).init_per_thread_state() + except AttributeError: + # If we're not on requests 2.8.0+ this method does not exist + pass + + def handle_407(self, r, **kwargs): + """Handle HTTP 407 only once, otherwise give up + + :param r: current response + :returns: responses, along with the new response + """ + if r.status_code == 407 and self.stale_rejects < 2: + s_auth = r.headers.get("proxy-authenticate") + if s_auth is None: + raise IOError( + "proxy server violated RFC 7235:" + "407 response MUST contain header proxy-authenticate") + elif not self._pat.match(s_auth): + return r + + self.chal = utils.parse_dict_header( + self._pat.sub('', s_auth, count=1)) + + # if we present the user/passwd and still get rejected + # https://tools.ietf.org/html/rfc2617#section-3.2.1 + if ('Proxy-Authorization' in r.request.headers and + 'stale' in self.chal): + if self.chal['stale'].lower() == 'true': # try again + self.stale_rejects += 1 + # wrong user/passwd + elif self.chal['stale'].lower() == 'false': + raise IOError("User or password is invalid") + + # Consume content and release the original connection + # to allow our new request to reuse the same one. + r.content + r.close() + prep = r.request.copy() + cookies.extract_cookies_to_jar(prep._cookies, r.request, r.raw) + prep.prepare_cookies(prep._cookies) + + prep.headers['Proxy-Authorization'] = self.build_digest_header( + prep.method, prep.url) + _r = r.connection.send(prep, **kwargs) + _r.history.append(r) + _r.request = prep + + return _r + else: # give up authenticate + return r + + def __call__(self, r): + self.init_per_thread_state() + # if we have nonce, then just use it, otherwise server will tell us + if self.last_nonce: + r.headers['Proxy-Authorization'] = self.build_digest_header( + r.method, r.url + ) + r.register_hook('response', self.handle_407) + return r diff --git a/requests_toolbelt/cookies/__init__.py b/requests_toolbelt/cookies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requests_toolbelt/cookies/forgetful.py b/requests_toolbelt/cookies/forgetful.py new file mode 100644 index 0000000..3320363 --- /dev/null +++ b/requests_toolbelt/cookies/forgetful.py @@ -0,0 +1,7 @@ +"""The module containing the code for ForgetfulCookieJar.""" +from requests.cookies import RequestsCookieJar + + +class ForgetfulCookieJar(RequestsCookieJar): + def set_cookie(self, *args, **kwargs): + return diff --git a/requests_toolbelt/downloadutils/__init__.py b/requests_toolbelt/downloadutils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requests_toolbelt/downloadutils/stream.py b/requests_toolbelt/downloadutils/stream.py new file mode 100644 index 0000000..7253d96 --- /dev/null +++ b/requests_toolbelt/downloadutils/stream.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +"""Utilities for dealing with streamed requests.""" +import os.path +import re + +from .. import exceptions as exc + +# Regular expressions stolen from werkzeug/http.py +# cd2c97bb0a076da2322f11adce0b2731f9193396 L62-L64 +_QUOTED_STRING_RE = r'"[^"\\]*(?:\\.[^"\\]*)*"' +_OPTION_HEADER_PIECE_RE = re.compile( + r';\s*(%s|[^\s;=]+)\s*(?:=\s*(%s|[^;]+))?\s*' % (_QUOTED_STRING_RE, + _QUOTED_STRING_RE) +) +_DEFAULT_CHUNKSIZE = 512 + + +def _get_filename(content_disposition): + for match in _OPTION_HEADER_PIECE_RE.finditer(content_disposition): + k, v = match.groups() + if k == 'filename': + # ignore any directory paths in the filename + return os.path.split(v)[1] + return None + + +def get_download_file_path(response, path): + """ + Given a response and a path, return a file path for a download. + + If a ``path`` parameter is a directory, this function will parse the + ``Content-Disposition`` header on the response to determine the name of the + file as reported by the server, and return a file path in the specified + directory. + + If ``path`` is empty or None, this function will return a path relative + to the process' current working directory. + + If path is a full file path, return it. + + :param response: A Response object from requests + :type response: requests.models.Response + :param str path: Directory or file path. + :returns: full file path to download as + :rtype: str + :raises: :class:`requests_toolbelt.exceptions.StreamingError` + """ + path_is_dir = path and os.path.isdir(path) + + if path and not path_is_dir: + # fully qualified file path + filepath = path + else: + response_filename = _get_filename( + response.headers.get('content-disposition', '') + ) + if not response_filename: + raise exc.StreamingError('No filename given to stream response to') + + if path_is_dir: + # directory to download to + filepath = os.path.join(path, response_filename) + else: + # fallback to downloading to current working directory + filepath = response_filename + + return filepath + + +def stream_response_to_file(response, path=None, chunksize=_DEFAULT_CHUNKSIZE): + """Stream a response body to the specified file. + + Either use the ``path`` provided or use the name provided in the + ``Content-Disposition`` header. + + .. warning:: + + If you pass this function an open file-like object as the ``path`` + parameter, the function will not close that file for you. + + .. warning:: + + This function will not automatically close the response object + passed in as the ``response`` parameter. + + If a ``path`` parameter is a directory, this function will parse the + ``Content-Disposition`` header on the response to determine the name of the + file as reported by the server, and return a file path in the specified + directory. If no ``path`` parameter is supplied, this function will default + to the process' current working directory. + + .. code-block:: python + + import requests + from requests_toolbelt import exceptions + from requests_toolbelt.downloadutils import stream + + r = requests.get(url, stream=True) + try: + filename = stream.stream_response_to_file(r) + except exceptions.StreamingError as e: + # The toolbelt could not find the filename in the + # Content-Disposition + print(e.message) + + You can also specify the filename as a string. This will be passed to + the built-in :func:`open` and we will read the content into the file. + + .. code-block:: python + + import requests + from requests_toolbelt.downloadutils import stream + + r = requests.get(url, stream=True) + filename = stream.stream_response_to_file(r, path='myfile') + + If the calculated download file path already exists, this function will + raise a StreamingError. + + Instead, if you want to manage the file object yourself, you need to + provide either a :class:`io.BytesIO` object or a file opened with the + `'b'` flag. See the two examples below for more details. + + .. code-block:: python + + import requests + from requests_toolbelt.downloadutils import stream + + with open('myfile', 'wb') as fd: + r = requests.get(url, stream=True) + filename = stream.stream_response_to_file(r, path=fd) + + print('{} saved to {}'.format(url, filename)) + + .. code-block:: python + + import io + import requests + from requests_toolbelt.downloadutils import stream + + b = io.BytesIO() + r = requests.get(url, stream=True) + filename = stream.stream_response_to_file(r, path=b) + assert filename is None + + :param response: A Response object from requests + :type response: requests.models.Response + :param path: *(optional)*, Either a string with the path to the location + to save the response content, or a file-like object expecting bytes. + :type path: :class:`str`, or object with a :meth:`write` + :param int chunksize: (optional), Size of chunk to attempt to stream + (default 512B). + :returns: The name of the file, if one can be determined, else None + :rtype: str + :raises: :class:`requests_toolbelt.exceptions.StreamingError` + """ + pre_opened = False + fd = None + filename = None + if path and callable(getattr(path, 'write', None)): + pre_opened = True + fd = path + filename = getattr(fd, 'name', None) + else: + filename = get_download_file_path(response, path) + if os.path.exists(filename): + raise exc.StreamingError("File already exists: %s" % filename) + fd = open(filename, 'wb') + + for chunk in response.iter_content(chunk_size=chunksize): + fd.write(chunk) + + if not pre_opened: + fd.close() + + return filename diff --git a/requests_toolbelt/downloadutils/tee.py b/requests_toolbelt/downloadutils/tee.py new file mode 100644 index 0000000..ecc7d0c --- /dev/null +++ b/requests_toolbelt/downloadutils/tee.py @@ -0,0 +1,123 @@ +"""Tee function implementations.""" +import io + +_DEFAULT_CHUNKSIZE = 65536 + +__all__ = ['tee', 'tee_to_file', 'tee_to_bytearray'] + + +def _tee(response, callback, chunksize, decode_content): + for chunk in response.raw.stream(amt=chunksize, + decode_content=decode_content): + callback(chunk) + yield chunk + + +def tee(response, fileobject, chunksize=_DEFAULT_CHUNKSIZE, + decode_content=None): + """Stream the response both to the generator and a file. + + This will stream the response body while writing the bytes to + ``fileobject``. + + Example usage: + + .. code-block:: python + + resp = requests.get(url, stream=True) + with open('save_file', 'wb') as save_file: + for chunk in tee(resp, save_file): + # do stuff with chunk + + .. code-block:: python + + import io + + resp = requests.get(url, stream=True) + fileobject = io.BytesIO() + + for chunk in tee(resp, fileobject): + # do stuff with chunk + + :param response: Response from requests. + :type response: requests.Response + :param fileobject: Writable file-like object. + :type fileobject: file, io.BytesIO + :param int chunksize: (optional), Size of chunk to attempt to stream. + :param bool decode_content: (optional), If True, this will decode the + compressed content of the response. + :raises: TypeError if the fileobject wasn't opened with the right mode + or isn't a BytesIO object. + """ + # We will be streaming the raw bytes from over the wire, so we need to + # ensure that writing to the fileobject will preserve those bytes. On + # Python3, if the user passes an io.StringIO, this will fail, so we need + # to check for BytesIO instead. + if not ('b' in getattr(fileobject, 'mode', '') or + isinstance(fileobject, io.BytesIO)): + raise TypeError('tee() will write bytes directly to this fileobject' + ', it must be opened with the "b" flag if it is a file' + ' or inherit from io.BytesIO.') + + return _tee(response, fileobject.write, chunksize, decode_content) + + +def tee_to_file(response, filename, chunksize=_DEFAULT_CHUNKSIZE, + decode_content=None): + """Stream the response both to the generator and a file. + + This will open a file named ``filename`` and stream the response body + while writing the bytes to the opened file object. + + Example usage: + + .. code-block:: python + + resp = requests.get(url, stream=True) + for chunk in tee_to_file(resp, 'save_file'): + # do stuff with chunk + + :param response: Response from requests. + :type response: requests.Response + :param str filename: Name of file in which we write the response content. + :param int chunksize: (optional), Size of chunk to attempt to stream. + :param bool decode_content: (optional), If True, this will decode the + compressed content of the response. + """ + with open(filename, 'wb') as fd: + for chunk in tee(response, fd, chunksize, decode_content): + yield chunk + + +def tee_to_bytearray(response, bytearr, chunksize=_DEFAULT_CHUNKSIZE, + decode_content=None): + """Stream the response both to the generator and a bytearray. + + This will stream the response provided to the function, add them to the + provided :class:`bytearray` and yield them to the user. + + .. note:: + + This uses the :meth:`bytearray.extend` by default instead of passing + the bytearray into the ``readinto`` method. + + Example usage: + + .. code-block:: python + + b = bytearray() + resp = requests.get(url, stream=True) + for chunk in tee_to_bytearray(resp, b): + # do stuff with chunk + + :param response: Response from requests. + :type response: requests.Response + :param bytearray bytearr: Array to add the streamed bytes to. + :param int chunksize: (optional), Size of chunk to attempt to stream. + :param bool decode_content: (optional), If True, this will decode the + compressed content of the response. + """ + if not isinstance(bytearr, bytearray): + raise TypeError('tee_to_bytearray() expects bytearr to be a ' + 'bytearray') + return _tee(response, bytearr.extend, chunksize, decode_content) diff --git a/requests_toolbelt/exceptions.py b/requests_toolbelt/exceptions.py new file mode 100644 index 0000000..32ade21 --- /dev/null +++ b/requests_toolbelt/exceptions.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +"""Collection of exceptions raised by requests-toolbelt.""" + + +class StreamingError(Exception): + """Used in :mod:`requests_toolbelt.downloadutils.stream`.""" + pass + + +class VersionMismatchError(Exception): + """Used to indicate a version mismatch in the version of requests required. + + The feature in use requires a newer version of Requests to function + appropriately but the version installed is not sufficient. + """ + pass + + +class RequestsVersionTooOld(Warning): + """Used to indicate that the Requests version is too old. + + If the version of Requests is too old to support a feature, we will issue + this warning to the user. + """ + pass + + +class IgnoringGAECertificateValidation(Warning): + """Used to indicate that given GAE validation behavior will be ignored. + + If the user has tried to specify certificate validation when using the + insecure AppEngine adapter, it will be ignored (certificate validation will + remain off), so we will issue this warning to the user. + + In :class:`requests_toolbelt.adapters.appengine.InsecureAppEngineAdapter`. + """ + pass diff --git a/requests_toolbelt/multipart/__init__.py b/requests_toolbelt/multipart/__init__.py new file mode 100644 index 0000000..d3bced1 --- /dev/null +++ b/requests_toolbelt/multipart/__init__.py @@ -0,0 +1,31 @@ +""" +requests_toolbelt.multipart +=========================== + +See https://toolbelt.readthedocs.io/ for documentation + +:copyright: (c) 2014 by Ian Cordasco and Cory Benfield +:license: Apache v2.0, see LICENSE for more details +""" + +from .encoder import MultipartEncoder, MultipartEncoderMonitor +from .decoder import MultipartDecoder +from .decoder import ImproperBodyPartContentException +from .decoder import NonMultipartContentTypeException + +__title__ = 'requests-toolbelt' +__authors__ = 'Ian Cordasco, Cory Benfield' +__license__ = 'Apache v2.0' +__copyright__ = 'Copyright 2014 Ian Cordasco, Cory Benfield' + +__all__ = [ + 'MultipartEncoder', + 'MultipartEncoderMonitor', + 'MultipartDecoder', + 'ImproperBodyPartContentException', + 'NonMultipartContentTypeException', + '__title__', + '__authors__', + '__license__', + '__copyright__', +] diff --git a/requests_toolbelt/multipart/decoder.py b/requests_toolbelt/multipart/decoder.py new file mode 100644 index 0000000..2a0d1c4 --- /dev/null +++ b/requests_toolbelt/multipart/decoder.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +""" + +requests_toolbelt.multipart.decoder +=================================== + +This holds all the implementation details of the MultipartDecoder + +""" + +import sys +import email.parser +from .encoder import encode_with +from requests.structures import CaseInsensitiveDict + + +def _split_on_find(content, bound): + point = content.find(bound) + return content[:point], content[point + len(bound):] + + +class ImproperBodyPartContentException(Exception): + pass + + +class NonMultipartContentTypeException(Exception): + pass + + +def _header_parser(string, encoding): + major = sys.version_info[0] + if major == 3: + string = string.decode(encoding) + headers = email.parser.HeaderParser().parsestr(string).items() + return ( + (encode_with(k, encoding), encode_with(v, encoding)) + for k, v in headers + ) + + +class BodyPart(object): + """ + + The ``BodyPart`` object is a ``Response``-like interface to an individual + subpart of a multipart response. It is expected that these will + generally be created by objects of the ``MultipartDecoder`` class. + + Like ``Response``, there is a ``CaseInsensitiveDict`` object named headers, + ``content`` to access bytes, ``text`` to access unicode, and ``encoding`` + to access the unicode codec. + + """ + + def __init__(self, content, encoding): + self.encoding = encoding + headers = {} + # Split into header section (if any) and the content + if b'\r\n\r\n' in content: + first, self.content = _split_on_find(content, b'\r\n\r\n') + if first != b'': + headers = _header_parser(first.lstrip(), encoding) + else: + raise ImproperBodyPartContentException( + 'content does not contain CR-LF-CR-LF' + ) + self.headers = CaseInsensitiveDict(headers) + + @property + def text(self): + """Content of the ``BodyPart`` in unicode.""" + return self.content.decode(self.encoding) + + +class MultipartDecoder(object): + """ + + The ``MultipartDecoder`` object parses the multipart payload of + a bytestring into a tuple of ``Response``-like ``BodyPart`` objects. + + The basic usage is:: + + import requests + from requests_toolbelt import MultipartDecoder + + response = requests.get(url) + decoder = MultipartDecoder.from_response(response) + for part in decoder.parts: + print(part.headers['content-type']) + + If the multipart content is not from a response, basic usage is:: + + from requests_toolbelt import MultipartDecoder + + decoder = MultipartDecoder(content, content_type) + for part in decoder.parts: + print(part.headers['content-type']) + + For both these usages, there is an optional ``encoding`` parameter. This is + a string, which is the name of the unicode codec to use (default is + ``'utf-8'``). + + """ + def __init__(self, content, content_type, encoding='utf-8'): + #: Original Content-Type header + self.content_type = content_type + #: Response body encoding + self.encoding = encoding + #: Parsed parts of the multipart response body + self.parts = tuple() + self._find_boundary() + self._parse_body(content) + + def _find_boundary(self): + ct_info = tuple(x.strip() for x in self.content_type.split(';')) + mimetype = ct_info[0] + if mimetype.split('/')[0].lower() != 'multipart': + raise NonMultipartContentTypeException( + "Unexpected mimetype in content-type: '{}'".format(mimetype) + ) + for item in ct_info[1:]: + attr, value = _split_on_find( + item, + '=' + ) + if attr.lower() == 'boundary': + self.boundary = encode_with(value.strip('"'), self.encoding) + + @staticmethod + def _fix_first_part(part, boundary_marker): + bm_len = len(boundary_marker) + if boundary_marker == part[:bm_len]: + return part[bm_len:] + else: + return part + + def _parse_body(self, content): + boundary = b''.join((b'--', self.boundary)) + + def body_part(part): + fixed = MultipartDecoder._fix_first_part(part, boundary) + return BodyPart(fixed, self.encoding) + + def test_part(part): + return (part != b'' and + part != b'\r\n' and + part[:4] != b'--\r\n' and + part != b'--') + + parts = content.split(b''.join((b'\r\n', boundary))) + self.parts = tuple(body_part(x) for x in parts if test_part(x)) + + @classmethod + def from_response(cls, response, encoding='utf-8'): + content = response.content + content_type = response.headers.get('content-type', None) + return cls(content, content_type, encoding) diff --git a/requests_toolbelt/multipart/encoder.py b/requests_toolbelt/multipart/encoder.py new file mode 100644 index 0000000..2d53961 --- /dev/null +++ b/requests_toolbelt/multipart/encoder.py @@ -0,0 +1,655 @@ +# -*- coding: utf-8 -*- +""" + +requests_toolbelt.multipart.encoder +=================================== + +This holds all of the implementation details of the MultipartEncoder + +""" +import contextlib +import io +import os +from uuid import uuid4 + +import requests + +from .._compat import fields + + +class FileNotSupportedError(Exception): + """File not supported error.""" + + +class MultipartEncoder(object): + + """ + + The ``MultipartEncoder`` object is a generic interface to the engine that + will create a ``multipart/form-data`` body for you. + + The basic usage is: + + .. code-block:: python + + import requests + from requests_toolbelt import MultipartEncoder + + encoder = MultipartEncoder({'field': 'value', + 'other_field': 'other_value'}) + r = requests.post('https://httpbin.org/post', data=encoder, + headers={'Content-Type': encoder.content_type}) + + If you do not need to take advantage of streaming the post body, you can + also do: + + .. code-block:: python + + r = requests.post('https://httpbin.org/post', + data=encoder.to_string(), + headers={'Content-Type': encoder.content_type}) + + If you want the encoder to use a specific order, you can use an + OrderedDict or more simply, a list of tuples: + + .. code-block:: python + + encoder = MultipartEncoder([('field', 'value'), + ('other_field', 'other_value')]) + + .. versionchanged:: 0.4.0 + + You can also provide tuples as part values as you would provide them to + requests' ``files`` parameter. + + .. code-block:: python + + encoder = MultipartEncoder({ + 'field': ('file_name', b'{"a": "b"}', 'application/json', + {'X-My-Header': 'my-value'}) + ]) + + .. warning:: + + This object will end up directly in :mod:`httplib`. Currently, + :mod:`httplib` has a hard-coded read size of **8192 bytes**. This + means that it will loop until the file has been read and your upload + could take a while. This is **not** a bug in requests. A feature is + being considered for this object to allow you, the user, to specify + what size should be returned on a read. If you have opinions on this, + please weigh in on `this issue`_. + + .. _this issue: + https://github.com/requests/toolbelt/issues/75 + + """ + + def __init__(self, fields, boundary=None, encoding='utf-8'): + #: Boundary value either passed in by the user or created + self.boundary_value = boundary or uuid4().hex + + # Computed boundary + self.boundary = '--{}'.format(self.boundary_value) + + #: Encoding of the data being passed in + self.encoding = encoding + + # Pre-encoded boundary + self._encoded_boundary = b''.join([ + encode_with(self.boundary, self.encoding), + encode_with('\r\n', self.encoding) + ]) + + #: Fields provided by the user + self.fields = fields + + #: Whether or not the encoder is finished + self.finished = False + + #: Pre-computed parts of the upload + self.parts = [] + + # Pre-computed parts iterator + self._iter_parts = iter([]) + + # The part we're currently working with + self._current_part = None + + # Cached computation of the body's length + self._len = None + + # Our buffer + self._buffer = CustomBytesIO(encoding=encoding) + + # Pre-compute each part's headers + self._prepare_parts() + + # Load boundary into buffer + self._write_boundary() + + @property + def len(self): + """Length of the multipart/form-data body. + + requests will first attempt to get the length of the body by calling + ``len(body)`` and then by checking for the ``len`` attribute. + + On 32-bit systems, the ``__len__`` method cannot return anything + larger than an integer (in C) can hold. If the total size of the body + is even slightly larger than 4GB users will see an OverflowError. This + manifested itself in `bug #80`_. + + As such, we now calculate the length lazily as a property. + + .. _bug #80: + https://github.com/requests/toolbelt/issues/80 + """ + # If _len isn't already calculated, calculate, return, and set it + return self._len or self._calculate_length() + + def __repr__(self): + return ''.format(self.fields) + + def _calculate_length(self): + """ + This uses the parts to calculate the length of the body. + + This returns the calculated length so __len__ can be lazy. + """ + boundary_len = len(self.boundary) # Length of --{boundary} + # boundary length + header length + body length + len('\r\n') * 2 + self._len = sum( + (boundary_len + total_len(p) + 4) for p in self.parts + ) + boundary_len + 4 + return self._len + + def _calculate_load_amount(self, read_size): + """This calculates how many bytes need to be added to the buffer. + + When a consumer read's ``x`` from the buffer, there are two cases to + satisfy: + + 1. Enough data in the buffer to return the requested amount + 2. Not enough data + + This function uses the amount of unread bytes in the buffer and + determines how much the Encoder has to load before it can return the + requested amount of bytes. + + :param int read_size: the number of bytes the consumer requests + :returns: int -- the number of bytes that must be loaded into the + buffer before the read can be satisfied. This will be strictly + non-negative + """ + amount = read_size - total_len(self._buffer) + return amount if amount > 0 else 0 + + def _load(self, amount): + """Load ``amount`` number of bytes into the buffer.""" + self._buffer.smart_truncate() + part = self._current_part or self._next_part() + while amount == -1 or amount > 0: + written = 0 + if part and not part.bytes_left_to_write(): + written += self._write(b'\r\n') + written += self._write_boundary() + part = self._next_part() + + if not part: + written += self._write_closing_boundary() + self.finished = True + break + + written += part.write_to(self._buffer, amount) + + if amount != -1: + amount -= written + + def _next_part(self): + try: + p = self._current_part = next(self._iter_parts) + except StopIteration: + p = None + return p + + def _iter_fields(self): + _fields = self.fields + if hasattr(self.fields, 'items'): + _fields = list(self.fields.items()) + for k, v in _fields: + file_name = None + file_type = None + file_headers = None + if isinstance(v, (list, tuple)): + if len(v) == 2: + file_name, file_pointer = v + elif len(v) == 3: + file_name, file_pointer, file_type = v + else: + file_name, file_pointer, file_type, file_headers = v + else: + file_pointer = v + + field = fields.RequestField(name=k, data=file_pointer, + filename=file_name, + headers=file_headers) + field.make_multipart(content_type=file_type) + yield field + + def _prepare_parts(self): + """This uses the fields provided by the user and creates Part objects. + + It populates the `parts` attribute and uses that to create a + generator for iteration. + """ + enc = self.encoding + self.parts = [Part.from_field(f, enc) for f in self._iter_fields()] + self._iter_parts = iter(self.parts) + + def _write(self, bytes_to_write): + """Write the bytes to the end of the buffer. + + :param bytes bytes_to_write: byte-string (or bytearray) to append to + the buffer + :returns: int -- the number of bytes written + """ + return self._buffer.append(bytes_to_write) + + def _write_boundary(self): + """Write the boundary to the end of the buffer.""" + return self._write(self._encoded_boundary) + + def _write_closing_boundary(self): + """Write the bytes necessary to finish a multipart/form-data body.""" + with reset(self._buffer): + self._buffer.seek(-2, 2) + self._buffer.write(b'--\r\n') + return 2 + + def _write_headers(self, headers): + """Write the current part's headers to the buffer.""" + return self._write(encode_with(headers, self.encoding)) + + @property + def content_type(self): + return str( + 'multipart/form-data; boundary={}'.format(self.boundary_value) + ) + + def to_string(self): + """Return the entirety of the data in the encoder. + + .. note:: + + This simply reads all of the data it can. If you have started + streaming or reading data from the encoder, this method will only + return whatever data is left in the encoder. + + .. note:: + + This method affects the internal state of the encoder. Calling + this method will exhaust the encoder. + + :returns: the multipart message + :rtype: bytes + """ + + return self.read() + + def read(self, size=-1): + """Read data from the streaming encoder. + + :param int size: (optional), If provided, ``read`` will return exactly + that many bytes. If it is not provided, it will return the + remaining bytes. + :returns: bytes + """ + if self.finished: + return self._buffer.read(size) + + bytes_to_load = size + if bytes_to_load != -1 and bytes_to_load is not None: + bytes_to_load = self._calculate_load_amount(int(size)) + + self._load(bytes_to_load) + return self._buffer.read(size) + + +def IDENTITY(monitor): + return monitor + + +class MultipartEncoderMonitor(object): + + """ + An object used to monitor the progress of a :class:`MultipartEncoder`. + + The :class:`MultipartEncoder` should only be responsible for preparing and + streaming the data. For anyone who wishes to monitor it, they shouldn't be + using that instance to manage that as well. Using this class, they can + monitor an encoder and register a callback. The callback receives the + instance of the monitor. + + To use this monitor, you construct your :class:`MultipartEncoder` as you + normally would. + + .. code-block:: python + + from requests_toolbelt import (MultipartEncoder, + MultipartEncoderMonitor) + import requests + + def callback(monitor): + # Do something with this information + pass + + m = MultipartEncoder(fields={'field0': 'value0'}) + monitor = MultipartEncoderMonitor(m, callback) + headers = {'Content-Type': monitor.content_type} + r = requests.post('https://httpbin.org/post', data=monitor, + headers=headers) + + Alternatively, if your use case is very simple, you can use the following + pattern. + + .. code-block:: python + + from requests_toolbelt import MultipartEncoderMonitor + import requests + + def callback(monitor): + # Do something with this information + pass + + monitor = MultipartEncoderMonitor.from_fields( + fields={'field0': 'value0'}, callback + ) + headers = {'Content-Type': montior.content_type} + r = requests.post('https://httpbin.org/post', data=monitor, + headers=headers) + + """ + + def __init__(self, encoder, callback=None): + #: Instance of the :class:`MultipartEncoder` being monitored + self.encoder = encoder + + #: Optionally function to call after a read + self.callback = callback or IDENTITY + + #: Number of bytes already read from the :class:`MultipartEncoder` + #: instance + self.bytes_read = 0 + + #: Avoid the same problem in bug #80 + self.len = self.encoder.len + + @classmethod + def from_fields(cls, fields, boundary=None, encoding='utf-8', + callback=None): + encoder = MultipartEncoder(fields, boundary, encoding) + return cls(encoder, callback) + + @property + def content_type(self): + return self.encoder.content_type + + def to_string(self): + return self.read() + + def read(self, size=-1): + string = self.encoder.read(size) + self.bytes_read += len(string) + self.callback(self) + return string + + +def encode_with(string, encoding): + """Encoding ``string`` with ``encoding`` if necessary. + + :param str string: If string is a bytes object, it will not encode it. + Otherwise, this function will encode it with the provided encoding. + :param str encoding: The encoding with which to encode string. + :returns: encoded bytes object + """ + if not (string is None or isinstance(string, bytes)): + return string.encode(encoding) + return string + + +def readable_data(data, encoding): + """Coerce the data to an object with a ``read`` method.""" + if hasattr(data, 'read'): + return data + + return CustomBytesIO(data, encoding) + + +def total_len(o): + if hasattr(o, '__len__'): + return len(o) + + if hasattr(o, 'len'): + return o.len + + if hasattr(o, 'fileno'): + try: + fileno = o.fileno() + except io.UnsupportedOperation: + pass + else: + return os.fstat(fileno).st_size + + if hasattr(o, 'getvalue'): + # e.g. BytesIO, cStringIO.StringIO + return len(o.getvalue()) + + +@contextlib.contextmanager +def reset(buffer): + """Keep track of the buffer's current position and write to the end. + + This is a context manager meant to be used when adding data to the buffer. + It eliminates the need for every function to be concerned with the + position of the cursor in the buffer. + """ + original_position = buffer.tell() + buffer.seek(0, 2) + yield + buffer.seek(original_position, 0) + + +def coerce_data(data, encoding): + """Ensure that every object's __len__ behaves uniformly.""" + if not isinstance(data, CustomBytesIO): + if hasattr(data, 'getvalue'): + return CustomBytesIO(data.getvalue(), encoding) + + if hasattr(data, 'fileno'): + return FileWrapper(data) + + if not hasattr(data, 'read'): + return CustomBytesIO(data, encoding) + + return data + + +def to_list(fields): + if hasattr(fields, 'items'): + return list(fields.items()) + return list(fields) + + +class Part(object): + def __init__(self, headers, body): + self.headers = headers + self.body = body + self.headers_unread = True + self.len = len(self.headers) + total_len(self.body) + + @classmethod + def from_field(cls, field, encoding): + """Create a part from a Request Field generated by urllib3.""" + headers = encode_with(field.render_headers(), encoding) + body = coerce_data(field.data, encoding) + return cls(headers, body) + + def bytes_left_to_write(self): + """Determine if there are bytes left to write. + + :returns: bool -- ``True`` if there are bytes left to write, otherwise + ``False`` + """ + to_read = 0 + if self.headers_unread: + to_read += len(self.headers) + + return (to_read + total_len(self.body)) > 0 + + def write_to(self, buffer, size): + """Write the requested amount of bytes to the buffer provided. + + The number of bytes written may exceed size on the first read since we + load the headers ambitiously. + + :param CustomBytesIO buffer: buffer we want to write bytes to + :param int size: number of bytes requested to be written to the buffer + :returns: int -- number of bytes actually written + """ + written = 0 + if self.headers_unread: + written += buffer.append(self.headers) + self.headers_unread = False + + while total_len(self.body) > 0 and (size == -1 or written < size): + amount_to_read = size + if size != -1: + amount_to_read = size - written + written += buffer.append(self.body.read(amount_to_read)) + + return written + + +class CustomBytesIO(io.BytesIO): + def __init__(self, buffer=None, encoding='utf-8'): + buffer = encode_with(buffer, encoding) + super(CustomBytesIO, self).__init__(buffer) + + def _get_end(self): + current_pos = self.tell() + self.seek(0, 2) + length = self.tell() + self.seek(current_pos, 0) + return length + + @property + def len(self): + length = self._get_end() + return length - self.tell() + + def append(self, bytes): + with reset(self): + written = self.write(bytes) + return written + + def smart_truncate(self): + to_be_read = total_len(self) + already_read = self._get_end() - to_be_read + + if already_read >= to_be_read: + old_bytes = self.read() + self.seek(0, 0) + self.truncate() + self.write(old_bytes) + self.seek(0, 0) # We want to be at the beginning + + +class FileWrapper(object): + def __init__(self, file_object): + self.fd = file_object + + @property + def len(self): + return total_len(self.fd) - self.fd.tell() + + def read(self, length=-1): + return self.fd.read(length) + + +class FileFromURLWrapper(object): + """File from URL wrapper. + + The :class:`FileFromURLWrapper` object gives you the ability to stream file + from provided URL in chunks by :class:`MultipartEncoder`. + Provide a stateless solution for streaming file from one server to another. + You can use the :class:`FileFromURLWrapper` without a session or with + a session as demonstated by the examples below: + + .. code-block:: python + # no session + + import requests + from requests_toolbelt import MultipartEncoder, FileFromURLWrapper + + url = 'https://httpbin.org/image/png' + streaming_encoder = MultipartEncoder( + fields={ + 'file': FileFromURLWrapper(url) + } + ) + r = requests.post( + 'https://httpbin.org/post', data=streaming_encoder, + headers={'Content-Type': streaming_encoder.content_type} + ) + + .. code-block:: python + # using a session + + import requests + from requests_toolbelt import MultipartEncoder, FileFromURLWrapper + + session = requests.Session() + url = 'https://httpbin.org/image/png' + streaming_encoder = MultipartEncoder( + fields={ + 'file': FileFromURLWrapper(url, session=session) + } + ) + r = session.post( + 'https://httpbin.org/post', data=streaming_encoder, + headers={'Content-Type': streaming_encoder.content_type} + ) + + """ + + def __init__(self, file_url, session=None): + self.session = session or requests.Session() + requested_file = self._request_for_file(file_url) + self.len = int(requested_file.headers['content-length']) + self.raw_data = requested_file.raw + + def _request_for_file(self, file_url): + """Make call for file under provided URL.""" + response = self.session.get(file_url, stream=True) + content_length = response.headers.get('content-length', None) + if content_length is None: + error_msg = ( + "Data from provided URL {url} is not supported. Lack of " + "content-length Header in requested file response.".format( + url=file_url) + ) + raise FileNotSupportedError(error_msg) + elif not content_length.isdigit(): + error_msg = ( + "Data from provided URL {url} is not supported. content-length" + " header value is not a digit.".format(url=file_url) + ) + raise FileNotSupportedError(error_msg) + return response + + def read(self, chunk_size): + """Read file in chunks.""" + chunk_size = chunk_size if chunk_size >= 0 else self.len + chunk = self.raw_data.read(chunk_size) or b'' + self.len -= len(chunk) if chunk else 0 # left to read + return chunk diff --git a/requests_toolbelt/sessions.py b/requests_toolbelt/sessions.py new file mode 100644 index 0000000..c747596 --- /dev/null +++ b/requests_toolbelt/sessions.py @@ -0,0 +1,89 @@ +import requests + +from ._compat import urljoin + + +class BaseUrlSession(requests.Session): + """A Session with a URL that all requests will use as a base. + + Let's start by looking at a few examples: + + .. code-block:: python + + >>> from requests_toolbelt import sessions + >>> s = sessions.BaseUrlSession( + ... base_url='https://example.com/resource/') + >>> r = s.get('sub-resource/', params={'foo': 'bar'}) + >>> print(r.request.url) + https://example.com/resource/sub-resource/?foo=bar + + Our call to the ``get`` method will make a request to the URL passed in + when we created the Session and the partial resource name we provide. + We implement this by overriding the ``request`` method of the Session. + + Likewise, we override the ``prepare_request`` method so you can construct + a PreparedRequest in the same way: + + .. code-block:: python + + >>> from requests import Request + >>> from requests_toolbelt import sessions + >>> s = sessions.BaseUrlSession( + ... base_url='https://example.com/resource/') + >>> request = Request(method='GET', url='sub-resource/') + >>> prepared_request = s.prepare_request(request) + >>> r = s.send(prepared_request) + >>> print(r.request.url) + https://example.com/resource/sub-resource + + .. note:: + + The base URL that you provide and the path you provide are **very** + important. + + Let's look at another *similar* example + + .. code-block:: python + + >>> from requests_toolbelt import sessions + >>> s = sessions.BaseUrlSession( + ... base_url='https://example.com/resource/') + >>> r = s.get('/sub-resource/', params={'foo': 'bar'}) + >>> print(r.request.url) + https://example.com/sub-resource/?foo=bar + + The key difference here is that we called ``get`` with ``/sub-resource/``, + i.e., there was a leading ``/``. This changes how we create the URL + because we rely on :mod:`urllib.parse.urljoin`. + + To override how we generate the URL, sub-class this method and override the + ``create_url`` method. + + Based on implementation from + https://github.com/kennethreitz/requests/issues/2554#issuecomment-109341010 + """ + + base_url = None + + def __init__(self, base_url=None): + if base_url: + self.base_url = base_url + super(BaseUrlSession, self).__init__() + + def request(self, method, url, *args, **kwargs): + """Send the request after generating the complete URL.""" + url = self.create_url(url) + return super(BaseUrlSession, self).request( + method, url, *args, **kwargs + ) + + def prepare_request(self, request, *args, **kwargs): + """Prepare the request after generating the complete URL.""" + request.url = self.create_url(request.url) + return super(BaseUrlSession, self).prepare_request( + request, *args, **kwargs + ) + + def create_url(self, url): + """Create the URL based off this partial path.""" + return urljoin(self.base_url, url) diff --git a/requests_toolbelt/streaming_iterator.py b/requests_toolbelt/streaming_iterator.py new file mode 100644 index 0000000..64fd75f --- /dev/null +++ b/requests_toolbelt/streaming_iterator.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +""" + +requests_toolbelt.streaming_iterator +==================================== + +This holds the implementation details for the :class:`StreamingIterator`. It +is designed for the case where you, the user, know the size of the upload but +need to provide the data as an iterator. This class will allow you to specify +the size and stream the data without using a chunked transfer-encoding. + +""" +from requests.utils import super_len + +from .multipart.encoder import CustomBytesIO, encode_with + + +class StreamingIterator(object): + + """ + This class provides a way of allowing iterators with a known size to be + streamed instead of chunked. + + In requests, if you pass in an iterator it assumes you want to use + chunked transfer-encoding to upload the data, which not all servers + support well. Additionally, you may want to set the content-length + yourself to avoid this but that will not work. The only way to preempt + requests using a chunked transfer-encoding and forcing it to stream the + uploads is to mimic a very specific interace. Instead of having to know + these details you can instead just use this class. You simply provide the + size and iterator and pass the instance of StreamingIterator to requests + via the data parameter like so: + + .. code-block:: python + + from requests_toolbelt import StreamingIterator + + import requests + + # Let iterator be some generator that you already have and size be + # the size of the data produced by the iterator + + r = requests.post(url, data=StreamingIterator(size, iterator)) + + You can also pass file-like objects to :py:class:`StreamingIterator` in + case requests can't determize the filesize itself. This is the case with + streaming file objects like ``stdin`` or any sockets. Wrapping e.g. files + that are on disk with ``StreamingIterator`` is unnecessary, because + requests can determine the filesize itself. + + Naturally, you should also set the `Content-Type` of your upload + appropriately because the toolbelt will not attempt to guess that for you. + """ + + def __init__(self, size, iterator, encoding='utf-8'): + #: The expected size of the upload + self.size = int(size) + + if self.size < 0: + raise ValueError( + 'The size of the upload must be a positive integer' + ) + + #: Attribute that requests will check to determine the length of the + #: body. See bug #80 for more details + self.len = self.size + + #: Encoding the input data is using + self.encoding = encoding + + #: The iterator used to generate the upload data + self.iterator = iterator + + if hasattr(iterator, 'read'): + self._file = iterator + else: + self._file = _IteratorAsBinaryFile(iterator, encoding) + + def read(self, size=-1): + return encode_with(self._file.read(size), self.encoding) + + +class _IteratorAsBinaryFile(object): + def __init__(self, iterator, encoding='utf-8'): + #: The iterator used to generate the upload data + self.iterator = iterator + + #: Encoding the iterator is using + self.encoding = encoding + + # The buffer we use to provide the correct number of bytes requested + # during a read + self._buffer = CustomBytesIO() + + def _get_bytes(self): + try: + return encode_with(next(self.iterator), self.encoding) + except StopIteration: + return b'' + + def _load_bytes(self, size): + self._buffer.smart_truncate() + amount_to_load = size - super_len(self._buffer) + bytes_to_append = True + + while amount_to_load > 0 and bytes_to_append: + bytes_to_append = self._get_bytes() + amount_to_load -= self._buffer.append(bytes_to_append) + + def read(self, size=-1): + size = int(size) + if size == -1: + return b''.join(self.iterator) + + self._load_bytes(size) + return self._buffer.read(size) diff --git a/requests_toolbelt/threaded/__init__.py b/requests_toolbelt/threaded/__init__.py new file mode 100644 index 0000000..984f1e8 --- /dev/null +++ b/requests_toolbelt/threaded/__init__.py @@ -0,0 +1,97 @@ +""" +This module provides the API for ``requests_toolbelt.threaded``. + +The module provides a clean and simple API for making requests via a thread +pool. The thread pool will use sessions for increased performance. + +A simple use-case is: + +.. code-block:: python + + from requests_toolbelt import threaded + + urls_to_get = [{ + 'url': 'https://api.github.com/users/sigmavirus24', + 'method': 'GET', + }, { + 'url': 'https://api.github.com/repos/requests/toolbelt', + 'method': 'GET', + }, { + 'url': 'https://google.com', + 'method': 'GET', + }] + responses, errors = threaded.map(urls_to_get) + +By default, the threaded submodule will detect the number of CPUs your +computer has and use that if no other number of processes is selected. To +change this, always use the keyword argument ``num_processes``. Using the +above example, we would expand it like so: + +.. code-block:: python + + responses, errors = threaded.map(urls_to_get, num_processes=10) + +You can also customize how a :class:`requests.Session` is initialized by +creating a callback function: + +.. code-block:: python + + from requests_toolbelt import user_agent + + def initialize_session(session): + session.headers['User-Agent'] = user_agent('my-scraper', '0.1') + session.headers['Accept'] = 'application/json' + + responses, errors = threaded.map(urls_to_get, + initializer=initialize_session) + +.. autofunction:: requests_toolbelt.threaded.map + +Inspiration is blatantly drawn from the standard library's multiprocessing +library. See the following references: + +- multiprocessing's `pool source`_ + +- map and map_async `inspiration`_ + +.. _pool source: + https://hg.python.org/cpython/file/8ef4f75a8018/Lib/multiprocessing/pool.py +.. _inspiration: + https://hg.python.org/cpython/file/8ef4f75a8018/Lib/multiprocessing/pool.py#l340 +""" +from . import pool +from .._compat import queue + + +def map(requests, **kwargs): + r"""Simple interface to the threaded Pool object. + + This function takes a list of dictionaries representing requests to make + using Sessions in threads and returns a tuple where the first item is + a generator of successful responses and the second is a generator of + exceptions. + + :param list requests: + Collection of dictionaries representing requests to make with the Pool + object. + :param \*\*kwargs: + Keyword arguments that are passed to the + :class:`~requests_toolbelt.threaded.pool.Pool` object. + :returns: Tuple of responses and exceptions from the pool + :rtype: (:class:`~requests_toolbelt.threaded.pool.ThreadResponse`, + :class:`~requests_toolbelt.threaded.pool.ThreadException`) + """ + if not (requests and all(isinstance(r, dict) for r in requests)): + raise ValueError('map expects a list of dictionaries.') + + # Build our queue of requests + job_queue = queue.Queue() + for request in requests: + job_queue.put(request) + + # Ensure the user doesn't try to pass their own job_queue + kwargs['job_queue'] = job_queue + + threadpool = pool.Pool(**kwargs) + threadpool.join_all() + return threadpool.responses(), threadpool.exceptions() diff --git a/requests_toolbelt/threaded/pool.py b/requests_toolbelt/threaded/pool.py new file mode 100644 index 0000000..1fe8146 --- /dev/null +++ b/requests_toolbelt/threaded/pool.py @@ -0,0 +1,211 @@ +"""Module implementing the Pool for :mod:``requests_toolbelt.threaded``.""" +import multiprocessing +import requests + +from . import thread +from .._compat import queue + + +class Pool(object): + """Pool that manages the threads containing sessions. + + :param queue: + The queue you're expected to use to which you should add items. + :type queue: queue.Queue + :param initializer: + Function used to initialize an instance of ``session``. + :type initializer: collections.Callable + :param auth_generator: + Function used to generate new auth credentials for the session. + :type auth_generator: collections.Callable + :param int num_process: + Number of threads to create. + :param session: + :type session: requests.Session + """ + + def __init__(self, job_queue, initializer=None, auth_generator=None, + num_processes=None, session=requests.Session): + if num_processes is None: + num_processes = multiprocessing.cpu_count() or 1 + + if num_processes < 1: + raise ValueError("Number of processes should at least be 1.") + + self._job_queue = job_queue + self._response_queue = queue.Queue() + self._exc_queue = queue.Queue() + self._processes = num_processes + self._initializer = initializer or _identity + self._auth = auth_generator or _identity + self._session = session + self._pool = [ + thread.SessionThread(self._new_session(), self._job_queue, + self._response_queue, self._exc_queue) + for _ in range(self._processes) + ] + + def _new_session(self): + return self._auth(self._initializer(self._session())) + + @classmethod + def from_exceptions(cls, exceptions, **kwargs): + r"""Create a :class:`~Pool` from an :class:`~ThreadException`\ s. + + Provided an iterable that provides :class:`~ThreadException` objects, + this classmethod will generate a new pool to retry the requests that + caused the exceptions. + + :param exceptions: + Iterable that returns :class:`~ThreadException` + :type exceptions: iterable + :param kwargs: + Keyword arguments passed to the :class:`~Pool` initializer. + :returns: An initialized :class:`~Pool` object. + :rtype: :class:`~Pool` + """ + job_queue = queue.Queue() + for exc in exceptions: + job_queue.put(exc.request_kwargs) + + return cls(job_queue=job_queue, **kwargs) + + @classmethod + def from_urls(cls, urls, request_kwargs=None, **kwargs): + """Create a :class:`~Pool` from an iterable of URLs. + + :param urls: + Iterable that returns URLs with which we create a pool. + :type urls: iterable + :param dict request_kwargs: + Dictionary of other keyword arguments to provide to the request + method. + :param kwargs: + Keyword arguments passed to the :class:`~Pool` initializer. + :returns: An initialized :class:`~Pool` object. + :rtype: :class:`~Pool` + """ + request_dict = {'method': 'GET'} + request_dict.update(request_kwargs or {}) + job_queue = queue.Queue() + for url in urls: + job = request_dict.copy() + job.update({'url': url}) + job_queue.put(job) + + return cls(job_queue=job_queue, **kwargs) + + def exceptions(self): + """Iterate over all the exceptions in the pool. + + :returns: Generator of :class:`~ThreadException` + """ + while True: + exc = self.get_exception() + if exc is None: + break + yield exc + + def get_exception(self): + """Get an exception from the pool. + + :rtype: :class:`~ThreadException` + """ + try: + (request, exc) = self._exc_queue.get_nowait() + except queue.Empty: + return None + else: + return ThreadException(request, exc) + + def get_response(self): + """Get a response from the pool. + + :rtype: :class:`~ThreadResponse` + """ + try: + (request, response) = self._response_queue.get_nowait() + except queue.Empty: + return None + else: + return ThreadResponse(request, response) + + def responses(self): + """Iterate over all the responses in the pool. + + :returns: Generator of :class:`~ThreadResponse` + """ + while True: + resp = self.get_response() + if resp is None: + break + yield resp + + def join_all(self): + """Join all the threads to the master thread.""" + for session_thread in self._pool: + session_thread.join() + + +class ThreadProxy(object): + proxied_attr = None + + def __getattr__(self, attr): + """Proxy attribute accesses to the proxied object.""" + get = object.__getattribute__ + if attr not in self.attrs: + response = get(self, self.proxied_attr) + return getattr(response, attr) + else: + return get(self, attr) + + +class ThreadResponse(ThreadProxy): + """A wrapper around a requests Response object. + + This will proxy most attribute access actions to the Response object. For + example, if you wanted the parsed JSON from the response, you might do: + + .. code-block:: python + + thread_response = pool.get_response() + json = thread_response.json() + + """ + proxied_attr = 'response' + attrs = frozenset(['request_kwargs', 'response']) + + def __init__(self, request_kwargs, response): + #: The original keyword arguments provided to the queue + self.request_kwargs = request_kwargs + #: The wrapped response + self.response = response + + +class ThreadException(ThreadProxy): + """A wrapper around an exception raised during a request. + + This will proxy most attribute access actions to the exception object. For + example, if you wanted the message from the exception, you might do: + + .. code-block:: python + + thread_exc = pool.get_exception() + msg = thread_exc.message + + """ + proxied_attr = 'exception' + attrs = frozenset(['request_kwargs', 'exception']) + + def __init__(self, request_kwargs, exception): + #: The original keyword arguments provided to the queue + self.request_kwargs = request_kwargs + #: The captured and wrapped exception + self.exception = exception + + +def _identity(session_obj): + return session_obj + + +__all__ = ['ThreadException', 'ThreadResponse', 'Pool'] diff --git a/requests_toolbelt/threaded/thread.py b/requests_toolbelt/threaded/thread.py new file mode 100644 index 0000000..542813c --- /dev/null +++ b/requests_toolbelt/threaded/thread.py @@ -0,0 +1,53 @@ +"""Module containing the SessionThread class.""" +import threading +import uuid + +import requests.exceptions as exc + +from .._compat import queue + + +class SessionThread(object): + def __init__(self, initialized_session, job_queue, response_queue, + exception_queue): + self._session = initialized_session + self._jobs = job_queue + self._create_worker() + self._responses = response_queue + self._exceptions = exception_queue + + def _create_worker(self): + self._worker = threading.Thread( + target=self._make_request, + name=uuid.uuid4(), + ) + self._worker.daemon = True + self._worker._state = 0 + self._worker.start() + + def _handle_request(self, kwargs): + try: + response = self._session.request(**kwargs) + except exc.RequestException as e: + self._exceptions.put((kwargs, e)) + else: + self._responses.put((kwargs, response)) + finally: + self._jobs.task_done() + + def _make_request(self): + while True: + try: + kwargs = self._jobs.get_nowait() + except queue.Empty: + break + + self._handle_request(kwargs) + + def is_alive(self): + """Proxy to the thread's ``is_alive`` method.""" + return self._worker.is_alive() + + def join(self): + """Join this thread to the master thread.""" + self._worker.join() diff --git a/requests_toolbelt/utils/__init__.py b/requests_toolbelt/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requests_toolbelt/utils/deprecated.py b/requests_toolbelt/utils/deprecated.py new file mode 100644 index 0000000..c935783 --- /dev/null +++ b/requests_toolbelt/utils/deprecated.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""A collection of functions deprecated in requests.utils.""" +import re +import sys + +from requests import utils + +find_charset = re.compile( + br']', flags=re.I +).findall + +find_pragma = re.compile( + br']', flags=re.I +).findall + +find_xml = re.compile( + br'^<\?xml.*?encoding=["\']*(.+?)["\'>]' +).findall + + +def get_encodings_from_content(content): + """Return encodings from given content string. + + .. code-block:: python + + import requests + from requests_toolbelt.utils import deprecated + + r = requests.get(url) + encodings = deprecated.get_encodings_from_content(r) + + :param content: bytestring to extract encodings from + :type content: bytes + :return: encodings detected in the provided content + :rtype: list(str) + """ + encodings = (find_charset(content) + find_pragma(content) + + find_xml(content)) + if (3, 0) <= sys.version_info < (4, 0): + encodings = [encoding.decode('utf8') for encoding in encodings] + return encodings + + +def get_unicode_from_response(response): + """Return the requested content back in unicode. + + This will first attempt to retrieve the encoding from the response + headers. If that fails, it will use + :func:`requests_toolbelt.utils.deprecated.get_encodings_from_content` + to determine encodings from HTML elements. + + .. code-block:: python + + import requests + from requests_toolbelt.utils import deprecated + + r = requests.get(url) + text = deprecated.get_unicode_from_response(r) + + :param response: Response object to get unicode content from. + :type response: requests.models.Response + """ + tried_encodings = set() + + # Try charset from content-type + encoding = utils.get_encoding_from_headers(response.headers) + + if encoding: + try: + return str(response.content, encoding) + except UnicodeError: + tried_encodings.add(encoding.lower()) + + encodings = get_encodings_from_content(response.content) + + for _encoding in encodings: + _encoding = _encoding.lower() + if _encoding in tried_encodings: + continue + try: + return str(response.content, _encoding) + except UnicodeError: + tried_encodings.add(_encoding) + + # Fall back: + if encoding: + try: + return str(response.content, encoding, errors='replace') + except TypeError: + pass + return response.text diff --git a/requests_toolbelt/utils/dump.py b/requests_toolbelt/utils/dump.py new file mode 100644 index 0000000..dec0e37 --- /dev/null +++ b/requests_toolbelt/utils/dump.py @@ -0,0 +1,198 @@ +"""This module provides functions for dumping information about responses.""" +import collections + +from requests import compat + + +__all__ = ('dump_response', 'dump_all') + +HTTP_VERSIONS = { + 9: b'0.9', + 10: b'1.0', + 11: b'1.1', +} + +_PrefixSettings = collections.namedtuple('PrefixSettings', + ['request', 'response']) + + +class PrefixSettings(_PrefixSettings): + def __new__(cls, request, response): + request = _coerce_to_bytes(request) + response = _coerce_to_bytes(response) + return super(PrefixSettings, cls).__new__(cls, request, response) + + +def _get_proxy_information(response): + if getattr(response.connection, 'proxy_manager', False): + proxy_info = {} + request_url = response.request.url + if request_url.startswith('https://'): + proxy_info['method'] = 'CONNECT' + + proxy_info['request_path'] = request_url + return proxy_info + return None + + +def _format_header(name, value): + return (_coerce_to_bytes(name) + b': ' + _coerce_to_bytes(value) + + b'\r\n') + + +def _build_request_path(url, proxy_info): + uri = compat.urlparse(url) + proxy_url = proxy_info.get('request_path') + if proxy_url is not None: + request_path = _coerce_to_bytes(proxy_url) + return request_path, uri + + request_path = _coerce_to_bytes(uri.path) + if uri.query: + request_path += b'?' + _coerce_to_bytes(uri.query) + + return request_path, uri + + +def _dump_request_data(request, prefixes, bytearr, proxy_info=None): + if proxy_info is None: + proxy_info = {} + + prefix = prefixes.request + method = _coerce_to_bytes(proxy_info.pop('method', request.method)) + request_path, uri = _build_request_path(request.url, proxy_info) + + # HTTP/1.1 + bytearr.extend(prefix + method + b' ' + request_path + b' HTTP/1.1\r\n') + + # Host: OR host header specified by user + headers = request.headers.copy() + host_header = _coerce_to_bytes(headers.pop('Host', uri.netloc)) + bytearr.extend(prefix + b'Host: ' + host_header + b'\r\n') + + for name, value in headers.items(): + bytearr.extend(prefix + _format_header(name, value)) + + bytearr.extend(prefix + b'\r\n') + if request.body: + if isinstance(request.body, compat.basestring): + bytearr.extend(prefix + _coerce_to_bytes(request.body)) + else: + # In the event that the body is a file-like object, let's not try + # to read everything into memory. + bytearr.extend(b'<< Request body is not a string-like type >>') + bytearr.extend(b'\r\n') + bytearr.extend(b'\r\n') + + +def _dump_response_data(response, prefixes, bytearr): + prefix = prefixes.response + # Let's interact almost entirely with urllib3's response + raw = response.raw + + # Let's convert the version int from httplib to bytes + version_str = HTTP_VERSIONS.get(raw.version, b'?') + + # HTTP/ + bytearr.extend(prefix + b'HTTP/' + version_str + b' ' + + str(raw.status).encode('ascii') + b' ' + + _coerce_to_bytes(response.reason) + b'\r\n') + + headers = raw.headers + for name in headers.keys(): + for value in headers.getlist(name): + bytearr.extend(prefix + _format_header(name, value)) + + bytearr.extend(prefix + b'\r\n') + + bytearr.extend(response.content) + + +def _coerce_to_bytes(data): + if not isinstance(data, bytes) and hasattr(data, 'encode'): + data = data.encode('utf-8') + # Don't bail out with an exception if data is None + return data if data is not None else b'' + + +def dump_response(response, request_prefix=b'< ', response_prefix=b'> ', + data_array=None): + """Dump a single request-response cycle's information. + + This will take a response object and dump only the data that requests can + see for that single request-response cycle. + + Example:: + + import requests + from requests_toolbelt.utils import dump + + resp = requests.get('https://api.github.com/users/sigmavirus24') + data = dump.dump_response(resp) + print(data.decode('utf-8')) + + :param response: + The response to format + :type response: :class:`requests.Response` + :param request_prefix: (*optional*) + Bytes to prefix each line of the request data + :type request_prefix: :class:`bytes` + :param response_prefix: (*optional*) + Bytes to prefix each line of the response data + :type response_prefix: :class:`bytes` + :param data_array: (*optional*) + Bytearray to which we append the request-response cycle data + :type data_array: :class:`bytearray` + :returns: Formatted bytes of request and response information. + :rtype: :class:`bytearray` + """ + data = data_array if data_array is not None else bytearray() + prefixes = PrefixSettings(request_prefix, response_prefix) + + if not hasattr(response, 'request'): + raise ValueError('Response has no associated request') + + proxy_info = _get_proxy_information(response) + _dump_request_data(response.request, prefixes, data, + proxy_info=proxy_info) + _dump_response_data(response, prefixes, data) + return data + + +def dump_all(response, request_prefix=b'< ', response_prefix=b'> '): + """Dump all requests and responses including redirects. + + This takes the response returned by requests and will dump all + request-response pairs in the redirect history in order followed by the + final request-response. + + Example:: + + import requests + from requests_toolbelt.utils import dump + + resp = requests.get('https://httpbin.org/redirect/5') + data = dump.dump_all(resp) + print(data.decode('utf-8')) + + :param response: + The response to format + :type response: :class:`requests.Response` + :param request_prefix: (*optional*) + Bytes to prefix each line of the request data + :type request_prefix: :class:`bytes` + :param response_prefix: (*optional*) + Bytes to prefix each line of the response data + :type response_prefix: :class:`bytes` + :returns: Formatted bytes of request and response information. + :rtype: :class:`bytearray` + """ + data = bytearray() + + history = list(response.history[:]) + history.append(response) + + for response in history: + dump_response(response, request_prefix, response_prefix, data) + + return data diff --git a/requests_toolbelt/utils/formdata.py b/requests_toolbelt/utils/formdata.py new file mode 100644 index 0000000..b0a909d --- /dev/null +++ b/requests_toolbelt/utils/formdata.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""Implementation of nested form-data encoding function(s).""" +from .._compat import basestring +from .._compat import urlencode as _urlencode + + +__all__ = ('urlencode',) + + +def urlencode(query, *args, **kwargs): + """Handle nested form-data queries and serialize them appropriately. + + There are times when a website expects a nested form data query to be sent + but, the standard library's urlencode function does not appropriately + handle the nested structures. In that case, you need this function which + will flatten the structure first and then properly encode it for you. + + When using this to send data in the body of a request, make sure you + specify the appropriate Content-Type header for the request. + + .. code-block:: python + + import requests + from requests_toolbelt.utils import formdata + + query = { + 'my_dict': { + 'foo': 'bar', + 'biz': 'baz", + }, + 'a': 'b', + } + + resp = requests.get(url, params=formdata.urlencode(query)) + # or + resp = requests.post( + url, + data=formdata.urlencode(query), + headers={ + 'Content-Type': 'application/x-www-form-urlencoded' + }, + ) + + Similarly, you can specify a list of nested tuples, e.g., + + .. code-block:: python + + import requests + from requests_toolbelt.utils import formdata + + query = [ + ('my_list', [ + ('foo', 'bar'), + ('biz', 'baz'), + ]), + ('a', 'b'), + ] + + resp = requests.get(url, params=formdata.urlencode(query)) + # or + resp = requests.post( + url, + data=formdata.urlencode(query), + headers={ + 'Content-Type': 'application/x-www-form-urlencoded' + }, + ) + + For additional parameter and return information, see the official + `urlencode`_ documentation. + + .. _urlencode: + https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode + """ + expand_classes = (dict, list, tuple) + original_query_list = _to_kv_list(query) + + if not all(_is_two_tuple(i) for i in original_query_list): + raise ValueError("Expected query to be able to be converted to a " + "list comprised of length 2 tuples.") + + query_list = original_query_list + while any(isinstance(v, expand_classes) for _, v in query_list): + query_list = _expand_query_values(query_list) + + return _urlencode(query_list, *args, **kwargs) + + +def _to_kv_list(dict_or_list): + if hasattr(dict_or_list, 'items'): + return list(dict_or_list.items()) + return dict_or_list + + +def _is_two_tuple(item): + return isinstance(item, (list, tuple)) and len(item) == 2 + + +def _expand_query_values(original_query_list): + query_list = [] + for key, value in original_query_list: + if isinstance(value, basestring): + query_list.append((key, value)) + else: + key_fmt = key + '[%s]' + value_list = _to_kv_list(value) + query_list.extend((key_fmt % k, v) for k, v in value_list) + return query_list diff --git a/requests_toolbelt/utils/user_agent.py b/requests_toolbelt/utils/user_agent.py new file mode 100644 index 0000000..e9636a4 --- /dev/null +++ b/requests_toolbelt/utils/user_agent.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +import collections +import platform +import sys + + +def user_agent(name, version, extras=None): + """Return an internet-friendly user_agent string. + + The majority of this code has been wilfully stolen from the equivalent + function in Requests. + + :param name: The intended name of the user-agent, e.g. "python-requests". + :param version: The version of the user-agent, e.g. "0.0.1". + :param extras: List of two-item tuples that are added to the user-agent + string. + :returns: Formatted user-agent string + :rtype: str + """ + if extras is None: + extras = [] + + return UserAgentBuilder( + name, version + ).include_extras( + extras + ).include_implementation( + ).include_system().build() + + +class UserAgentBuilder(object): + """Class to provide a greater level of control than :func:`user_agent`. + + This is used by :func:`user_agent` to build its User-Agent string. + + .. code-block:: python + + user_agent_str = UserAgentBuilder( + name='requests-toolbelt', + version='17.4.0', + ).include_implementation( + ).include_system( + ).include_extras([ + ('requests', '2.14.2'), + ('urllib3', '1.21.2'), + ]).build() + + """ + + format_string = '%s/%s' + + def __init__(self, name, version): + """Initialize our builder with the name and version of our user agent. + + :param str name: + Name of our user-agent. + :param str version: + The version string for user-agent. + """ + self._pieces = collections.deque([(name, version)]) + + def build(self): + """Finalize the User-Agent string. + + :returns: + Formatted User-Agent string. + :rtype: + str + """ + return " ".join([self.format_string % piece for piece in self._pieces]) + + def include_extras(self, extras): + """Include extra portions of the User-Agent. + + :param list extras: + list of tuples of extra-name and extra-version + """ + if any(len(extra) != 2 for extra in extras): + raise ValueError('Extras should be a sequence of two item tuples.') + + self._pieces.extend(extras) + return self + + def include_implementation(self): + """Append the implementation string to the user-agent string. + + This adds the the information that you're using CPython 2.7.13 to the + User-Agent. + """ + self._pieces.append(_implementation_tuple()) + return self + + def include_system(self): + """Append the information about the Operating System.""" + self._pieces.append(_platform_tuple()) + return self + + +def _implementation_tuple(): + """Return the tuple of interpreter name and version. + + Returns a string that provides both the name and the version of the Python + implementation currently running. For example, on CPython 2.7.5 it will + return "CPython/2.7.5". + + This function works best on CPython and PyPy: in particular, it probably + doesn't work for Jython or IronPython. Future investigation should be done + to work out the correct shape of the code for those platforms. + """ + implementation = platform.python_implementation() + + if implementation == 'CPython': + implementation_version = platform.python_version() + elif implementation == 'PyPy': + implementation_version = '%s.%s.%s' % (sys.pypy_version_info.major, + sys.pypy_version_info.minor, + sys.pypy_version_info.micro) + if sys.pypy_version_info.releaselevel != 'final': + implementation_version = ''.join([ + implementation_version, sys.pypy_version_info.releaselevel + ]) + elif implementation == 'Jython': + implementation_version = platform.python_version() # Complete Guess + elif implementation == 'IronPython': + implementation_version = platform.python_version() # Complete Guess + else: + implementation_version = 'Unknown' + + return (implementation, implementation_version) + + +def _implementation_string(): + return "%s/%s" % _implementation_tuple() + + +def _platform_tuple(): + try: + p_system = platform.system() + p_release = platform.release() + except IOError: + p_system = 'Unknown' + p_release = 'Unknown' + return (p_system, p_release) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..adf5ed7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[bdist_wheel] +universal = 1 + +[egg_info] +tag_build = +tag_date = 0 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..82b155c --- /dev/null +++ b/setup.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os +import re +import sys + +from setuptools import setup + +if sys.argv[-1].lower() in ("submit", "publish"): + os.system("python setup.py bdist_wheel sdist upload") + sys.exit() + + +def get_version(): + version = '' + with open('requests_toolbelt/__init__.py', 'r') as fd: + reg = re.compile(r'__version__ = [\'"]([^\'"]*)[\'"]') + for line in fd: + m = reg.match(line) + if m: + version = m.group(1) + break + return version + +__version__ = get_version() + +if not __version__: + raise RuntimeError('Cannot find version information') + + +packages = [ + 'requests_toolbelt', + 'requests_toolbelt.adapters', + 'requests_toolbelt.auth', + 'requests_toolbelt.downloadutils', + 'requests_toolbelt.multipart', + 'requests_toolbelt.threaded', + 'requests_toolbelt.utils', +] + +setup( + name="requests-toolbelt", + version=__version__, + description="A utility belt for advanced users of python-requests", + long_description="\n\n".join([open("README.rst").read(), + open("HISTORY.rst").read()]), + long_description_content_type="text/x-rst", + license='Apache 2.0', + author='Ian Cordasco, Cory Benfield', + author_email="graffatcolmingov@gmail.com", + url="https://toolbelt.readthedocs.io/", + packages=packages, + package_data={'': ['LICENSE', 'AUTHORS.rst']}, + include_package_data=True, + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', + install_requires=['requests>=2.0.1,<3.0.0'], + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'License :: OSI Approved :: Apache Software License', + 'Intended Audience :: Developers', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a4bf96a --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +import betamax + + +def get_betamax(session): + return betamax.Betamax( + session, + cassette_library_dir='tests/cassettes') diff --git a/tests/cassettes/file_for_download.json b/tests/cassettes/file_for_download.json new file mode 100644 index 0000000..9e4bb8f --- /dev/null +++ b/tests/cassettes/file_for_download.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"uri": "https://stxnext.com/static/img/logo.830ebe551641.svg", "body": {"encoding": "utf-8", "string": ""}, "method": "GET", "headers": {"User-Agent": ["python-requests/2.2.1 CPython/3.5.2 Darwin/17.3.0"], "Accept-Encoding": ["gzip, deflate, compress"], "Accept": ["*/*"]}}, "recorded_at": "2018-01-04T23:00:12", "response": {"url": "https://stxnext.com/static/img/logo.830ebe551641.svg", "status": {"message": "OK", "code": 200}, "body": {"encoding": null, "string": ""}, "headers": {"date": ["Thu, 04 Jan 2018 23:00:15 GMT"], "strict-transport-security": ["max-age=0; includeSubdomains; preload"], "last-modified": ["Wed, 22 Nov 2017 09:22:00 GMT"], "content-type": ["image/svg+xml"], "content-length": ["5177"]}}}], "recorded_with": "betamax/0.8.0"} \ No newline at end of file diff --git a/tests/cassettes/http2bin_cookies.json b/tests/cassettes/http2bin_cookies.json new file mode 100644 index 0000000..a8368ed --- /dev/null +++ b/tests/cassettes/http2bin_cookies.json @@ -0,0 +1 @@ +{"recorded_with": "betamax/0.5.1", "http_interactions": [{"response": {"status": {"code": 302, "message": "FOUND"}, "body": {"string": "\nRedirecting...\n

Redirecting...

\n

You should be redirected automatically to target URL: /cookies. If not click the link.", "encoding": "utf-8"}, "url": "https://httpbin.org/cookies/set?cookie0=value0", "headers": {"Location": ["/cookies"], "Content-Length": ["223"], "Date": ["Fri, 13 Nov 2015 00:23:20 GMT"], "Access-Control-Allow-Credentials": ["true"], "Access-Control-Allow-Origin": ["*"], "Connection": ["keep-alive"], "Server": ["nginx"], "Set-Cookie": ["cookie0=value0; Path=/"], "Content-Type": ["text/html; charset=utf-8"]}}, "recorded_at": "2015-11-13T00:23:19", "request": {"uri": "https://httpbin.org/cookies/set?cookie0=value0", "method": "GET", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "User-Agent": ["python-requests/2.8.1"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"]}}}, {"response": {"status": {"code": 200, "message": "OK"}, "body": {"string": "{\n \"cookies\": {\n \"cookie0\": \"value0\"\n }\n}\n", "encoding": null}, "url": "https://httpbin.org/cookies", "headers": {"Access-Control-Allow-Credentials": ["true"], "Content-Length": ["47"], "Date": ["Fri, 13 Nov 2015 00:23:20 GMT"], "Content-Type": ["application/json"], "Connection": ["keep-alive"], "Server": ["nginx"], "Access-Control-Allow-Origin": ["*"]}}, "recorded_at": "2015-11-13T00:23:19", "request": {"uri": "https://httpbin.org/cookies", "method": "GET", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": ["keep-alive"], "User-Agent": ["python-requests/2.8.1"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Cookie": ["cookie0=value0"]}}}]} \ No newline at end of file diff --git a/tests/cassettes/http2bin_fingerprint.json b/tests/cassettes/http2bin_fingerprint.json new file mode 100644 index 0000000..fd2fd35 --- /dev/null +++ b/tests/cassettes/http2bin_fingerprint.json @@ -0,0 +1 @@ +{"recorded_with": "betamax/0.4.1", "http_interactions": [{"response": {"status": {"message": "OK", "code": 200}, "body": {"string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Connection\": \"keep-alive\", \n \"Host\": \"http2bin.org\", \n \"User-Agent\": \"python-requests/2.5.3 CPython/2.7.9 Darwin/14.1.0\"\n }, \n \"origin\": \"77.99.146.203\", \n \"url\": \"https://http2bin.org/get\"\n}\n", "encoding": null}, "headers": {"access-control-allow-origin": ["*"], "date": ["Tue, 03 Mar 2015 21:29:55 GMT"], "server": ["h2o/1.0.2-alpha1"], "content-length": ["301"], "access-control-allow-credentials": ["true"], "connection": ["keep-alive"], "content-type": ["application/json"]}, "url": "https://http2bin.org/get"}, "recorded_at": "2015-03-03T21:29:55", "request": {"method": "GET", "uri": "https://http2bin.org/get", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept": ["*/*"], "Accept-Encoding": ["gzip, deflate"], "Connection": ["keep-alive"], "User-Agent": ["python-requests/2.5.3 CPython/2.7.9 Darwin/14.1.0"]}}}]} \ No newline at end of file diff --git a/tests/cassettes/httpbin_guess_auth_basic.json b/tests/cassettes/httpbin_guess_auth_basic.json new file mode 100644 index 0000000..db72722 --- /dev/null +++ b/tests/cassettes/httpbin_guess_auth_basic.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept-Encoding": ["gzip, deflate, compress"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.2.1 CPython/2.7.6 Linux/3.14.1-1-ARCH"]}, "method": "GET", "uri": "http://httpbin.org/basic-auth/user/passwd"}, "response": {"body": {"string": "", "encoding": null}, "headers": {"content-length": ["0"], "server": ["gunicorn/0.17.4"], "connection": ["keep-alive"], "date": ["Sat, 03 May 2014 17:23:06 GMT"], "access-control-allow-origin": ["*"], "www-authenticate": ["Basic realm=\"Fake Realm\""]}, "status": {"message": "UNAUTHORIZED", "code": 401}, "url": "http://httpbin.org/basic-auth/user/passwd"}, "recorded_at": "2014-05-03T17:23:06"}, {"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept": ["*/*"], "Accept-Encoding": ["gzip, deflate, compress"], "Authorization": ["Basic dXNlcjpwYXNzd2Q="], "User-Agent": ["python-requests/2.2.1 CPython/2.7.6 Linux/3.14.1-1-ARCH"]}, "method": "GET", "uri": "http://httpbin.org/basic-auth/user/passwd"}, "response": {"body": {"string": "{\n \"user\": \"user\",\n \"authenticated\": true\n}", "encoding": null}, "headers": {"content-length": ["45"], "server": ["gunicorn/0.17.4"], "connection": ["keep-alive"], "date": ["Sat, 03 May 2014 17:23:06 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "http://httpbin.org/basic-auth/user/passwd"}, "recorded_at": "2014-05-03T17:23:06"}], "recorded_with": "betamax/{version}"} \ No newline at end of file diff --git a/tests/cassettes/httpbin_guess_auth_digest.json b/tests/cassettes/httpbin_guess_auth_digest.json new file mode 100644 index 0000000..94cf3a3 --- /dev/null +++ b/tests/cassettes/httpbin_guess_auth_digest.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept-Encoding": ["gzip, deflate, compress"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.2.1 CPython/2.7.6 Linux/3.14.1-1-ARCH"]}, "method": "GET", "uri": "http://httpbin.org/digest-auth/auth/user/passwd"}, "response": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"content-length": ["0"], "set-cookie": ["fake=fake_value"], "server": ["gunicorn/0.17.4"], "connection": ["keep-alive"], "date": ["Sat, 03 May 2014 17:23:07 GMT"], "access-control-allow-origin": ["*"], "content-type": ["text/html; charset=utf-8"], "www-authenticate": ["Digest qop=auth, nonce=\"713b4eb6d0ad0ac25d75b50c4d044d5e\", realm=\"me@kennethreitz.com\", opaque=\"d0033bc1960ca78a2fc4497c1e8a8cbd\""]}, "status": {"message": "UNAUTHORIZED", "code": 401}, "url": "http://httpbin.org/digest-auth/auth/user/passwd"}, "recorded_at": "2014-05-03T17:23:07"}, {"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept": ["*/*"], "Cookie": ["fake=fake_value"], "Accept-Encoding": ["gzip, deflate, compress"], "Authorization": ["Digest username=\"user\", realm=\"me@kennethreitz.com\", nonce=\"713b4eb6d0ad0ac25d75b50c4d044d5e\", uri=\"/digest-auth/auth/user/passwd\", response=\"30276b25ef0031e65e3bccc719031388\", opaque=\"d0033bc1960ca78a2fc4497c1e8a8cbd\", qop=\"auth\", nc=00000001, cnonce=\"e94e00be64d66bcb\""], "User-Agent": ["python-requests/2.2.1 CPython/2.7.6 Linux/3.14.1-1-ARCH"]}, "method": "GET", "uri": "http://httpbin.org/digest-auth/auth/user/passwd"}, "response": {"body": {"string": "{\n \"user\": \"user\",\n \"authenticated\": true\n}", "encoding": null}, "headers": {"content-length": ["45"], "server": ["gunicorn/0.17.4"], "connection": ["keep-alive"], "date": ["Sat, 03 May 2014 17:23:07 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "http://httpbin.org/digest-auth/auth/user/passwd"}, "recorded_at": "2014-05-03T17:23:07"}], "recorded_with": "betamax/{version}"} \ No newline at end of file diff --git a/tests/cassettes/httpbin_guess_auth_none.json b/tests/cassettes/httpbin_guess_auth_none.json new file mode 100644 index 0000000..2ebbb0f --- /dev/null +++ b/tests/cassettes/httpbin_guess_auth_none.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"string": "", "encoding": "utf-8"}, "headers": {"Accept-Encoding": ["gzip, deflate, compress"], "Accept": ["*/*"], "User-Agent": ["python-requests/2.2.1 CPython/2.7.6 Linux/3.14.1-1-ARCH"]}, "method": "GET", "uri": "http://httpbin.org/get?a=1"}, "response": {"body": {"string": "{\n \"args\": {\n \"a\": \"1\"\n },\n \"url\": \"http://httpbin.org/get?a=1\",\n \"headers\": {\n \"Connection\": \"close\",\n \"Host\": \"httpbin.org\",\n \"Accept-Encoding\": \"gzip, deflate, compress\",\n \"X-Request-Id\": \"f9f71f12-5705-4a0f-85d4-3d63f9140b1f\",\n \"User-Agent\": \"python-requests/2.2.1 CPython/2.7.6 Linux/3.14.1-1-ARCH\",\n \"Accept\": \"*/*\"\n },\n \"origin\": \"62.47.252.115\"\n}", "encoding": null}, "headers": {"content-length": ["381"], "server": ["gunicorn/0.17.4"], "connection": ["keep-alive"], "date": ["Sat, 03 May 2014 17:23:07 GMT"], "access-control-allow-origin": ["*"], "content-type": ["application/json"]}, "status": {"message": "OK", "code": 200}, "url": "http://httpbin.org/get?a=1"}, "recorded_at": "2014-05-03T17:23:07"}], "recorded_with": "betamax/{version}"} \ No newline at end of file diff --git a/tests/cassettes/klevas_vu_lt_ssl3.json b/tests/cassettes/klevas_vu_lt_ssl3.json new file mode 100644 index 0000000..3c667ba --- /dev/null +++ b/tests/cassettes/klevas_vu_lt_ssl3.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": "", "headers": {"Accept-Encoding": "gzip, deflate, compress", "Accept": "*/*", "User-Agent": "python-requests/2.1.0 CPython/2.7.3 Linux/3.2.29"}, "method": "GET", "uri": "https://klevas.vu.lt/"}, "response": {"body": {"string": "\n\nKlevas\n\n\n\n\n\n\n", "encoding": "ISO-8859-1"}, "headers": {"content-length": "204", "accept-ranges": "bytes", "server": "Oracle-Application-Server-10g/10.1.3.1.0 Oracle-HTTP-Server", "last-modified": "Wed, 13 Apr 2011 05:00:23 GMT", "etag": "\"7f9b-cc-4da52de7\"", "date": "Sun, 05 Jan 2014 01:35:40 GMT", "content-type": "text/html"}, "url": "https://klevas.vu.lt/", "status_code": 200}, "recorded_at": "2014-01-05T01:34:40"}], "recorded_with": "betamax"} \ No newline at end of file diff --git a/tests/cassettes/redirect_request_for_dump_all.json b/tests/cassettes/redirect_request_for_dump_all.json new file mode 100644 index 0000000..38b037d --- /dev/null +++ b/tests/cassettes/redirect_request_for_dump_all.json @@ -0,0 +1 @@ +{"recorded_with": "betamax/0.5.1", "http_interactions": [{"recorded_at": "2015-11-14T22:53:20", "request": {"uri": "https://httpbin.org/redirect/5", "method": "GET", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": "keep-alive", "Accept": "*/*", "User-Agent": "python-requests/2.8.1", "Accept-Encoding": "gzip, deflate"}}, "response": {"url": "https://httpbin.org/redirect/5", "status": {"code": 302, "message": "FOUND"}, "body": {"string": "\nRedirecting...\n

Redirecting...

\n

You should be redirected automatically to target URL: /relative-redirect/4. If not click the link.", "encoding": "utf-8"}, "headers": {"Location": "/relative-redirect/4", "Access-Control-Allow-Credentials": "true", "Server": "nginx", "Date": "Sat, 14 Nov 2015 22:53:18 GMT", "Content-Length": "247", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Content-Type": "text/html; charset=utf-8"}}}, {"recorded_at": "2015-11-14T22:53:20", "request": {"uri": "https://httpbin.org/relative-redirect/4", "method": "GET", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": "keep-alive", "Accept": "*/*", "User-Agent": "python-requests/2.8.1", "Accept-Encoding": "gzip, deflate"}}, "response": {"url": "https://httpbin.org/relative-redirect/4", "status": {"code": 302, "message": "FOUND"}, "body": {"string": "", "encoding": "utf-8"}, "headers": {"Location": "/relative-redirect/3", "Access-Control-Allow-Credentials": "true", "Server": "nginx", "Date": "Sat, 14 Nov 2015 22:53:18 GMT", "Content-Length": "0", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Content-Type": "text/html; charset=utf-8"}}}, {"recorded_at": "2015-11-14T22:53:20", "request": {"uri": "https://httpbin.org/relative-redirect/3", "method": "GET", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": "keep-alive", "Accept": "*/*", "User-Agent": "python-requests/2.8.1", "Accept-Encoding": "gzip, deflate"}}, "response": {"url": "https://httpbin.org/relative-redirect/3", "status": {"code": 302, "message": "FOUND"}, "body": {"string": "", "encoding": "utf-8"}, "headers": {"Location": "/relative-redirect/2", "Access-Control-Allow-Credentials": "true", "Server": "nginx", "Date": "Sat, 14 Nov 2015 22:53:18 GMT", "Content-Length": "0", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Content-Type": "text/html; charset=utf-8"}}}, {"recorded_at": "2015-11-14T22:53:20", "request": {"uri": "https://httpbin.org/relative-redirect/2", "method": "GET", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": "keep-alive", "Accept": "*/*", "User-Agent": "python-requests/2.8.1", "Accept-Encoding": "gzip, deflate"}}, "response": {"url": "https://httpbin.org/relative-redirect/2", "status": {"code": 302, "message": "FOUND"}, "body": {"string": "", "encoding": "utf-8"}, "headers": {"Location": "/relative-redirect/1", "Access-Control-Allow-Credentials": "true", "Server": "nginx", "Date": "Sat, 14 Nov 2015 22:53:18 GMT", "Content-Length": "0", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Content-Type": "text/html; charset=utf-8"}}}, {"recorded_at": "2015-11-14T22:53:20", "request": {"uri": "https://httpbin.org/relative-redirect/1", "method": "GET", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": "keep-alive", "Accept": "*/*", "User-Agent": "python-requests/2.8.1", "Accept-Encoding": "gzip, deflate"}}, "response": {"url": "https://httpbin.org/relative-redirect/1", "status": {"code": 302, "message": "FOUND"}, "body": {"string": "", "encoding": "utf-8"}, "headers": {"Location": "/get", "Access-Control-Allow-Credentials": "true", "Server": "nginx", "Date": "Sat, 14 Nov 2015 22:53:18 GMT", "Content-Length": "0", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Content-Type": "text/html; charset=utf-8"}}}, {"recorded_at": "2015-11-14T22:53:20", "request": {"uri": "https://httpbin.org/get", "method": "GET", "body": {"string": "", "encoding": "utf-8"}, "headers": {"Connection": "keep-alive", "Accept": "*/*", "User-Agent": "python-requests/2.8.1", "Accept-Encoding": "gzip, deflate"}}, "response": {"url": "https://httpbin.org/get", "status": {"code": 200, "message": "OK"}, "body": {"string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.8.1\"\n }, \n \"origin\": \"\", \n \"url\": \"https://httpbin.org/get\"\n}\n", "encoding": null}, "headers": {"Access-Control-Allow-Credentials": "true", "Server": "nginx", "Date": "Sat, 14 Nov 2015 22:53:18 GMT", "Content-Length": "239", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Content-Type": "application/json"}}}]} \ No newline at end of file diff --git a/tests/cassettes/simple_get_request.json b/tests/cassettes/simple_get_request.json new file mode 100644 index 0000000..a61fb5f --- /dev/null +++ b/tests/cassettes/simple_get_request.json @@ -0,0 +1 @@ +{"recorded_with": "betamax/0.5.1", "http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": ""}, "uri": "https://httpbin.org/get", "headers": {"Connection": ["keep-alive"], "User-Agent": ["python-requests/2.8.1"], "Accept": ["*/*"], "Accept-Encoding": ["gzip, deflate"]}, "method": "GET"}, "recorded_at": "2015-11-14T22:33:32", "response": {"status": {"code": 200, "message": "OK"}, "url": "https://httpbin.org/get", "body": {"encoding": null, "string": "{\n \"args\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\": \"gzip, deflate\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\": \"python-requests/2.8.1\"\n }, \n \"origin\": \"\", \n \"url\": \"https://httpbin.org/get\"\n}\n"}, "headers": {"Content-Type": ["application/json"], "Date": ["Sat, 14 Nov 2015 22:33:30 GMT"], "Connection": ["keep-alive"], "Server": ["nginx"], "Access-Control-Allow-Credentials": ["true"], "Content-Length": ["239"], "Access-Control-Allow-Origin": ["*"]}}}]} diff --git a/tests/cassettes/stream_response_to_file.json b/tests/cassettes/stream_response_to_file.json new file mode 100644 index 0000000..9807e41 --- /dev/null +++ b/tests/cassettes/stream_response_to_file.json @@ -0,0 +1 @@ +{"recorded_with": "betamax/0.4.1", "http_interactions": [{"request": {"uri": "https://api.github.com/repos/sigmavirus24/github3.py/releases/assets/37944", "method": "GET", "headers": {"Accept": ["application/octet-stream"], "Accept-Encoding": ["gzip, deflate"], "Connection": ["keep-alive"], "User-Agent": ["python-requests/2.5.3 CPython/2.7.9 Darwin/14.1.0"]}, "body": {"base64_string": "", "encoding": "utf-8"}}, "response": {"status": {"code": 302, "message": "Found"}, "url": "https://api.github.com/repos/sigmavirus24/github3.py/releases/assets/37944", "headers": {"access-control-allow-credentials": ["true"], "x-xss-protection": ["1; mode=block"], "vary": ["Accept-Encoding"], "location": ["https://s3.amazonaws.com/github-cloud/releases/3710711/365425c2-4e46-11e3-86fb-bb0d50a886e7.whl?response-content-disposition=attachment%3B%20filename%3Dgithub3.py-0.7.1-py2.py3-none-any.whl&response-content-type=application/octet-stream&AWSAccessKeyId=AKIAISTNZFOVBIJMK3TQ&Expires=1426166613&Signature=78anFgNgXLm3TIbo%2FbTEEk7m%2F34%3D"], "x-content-type-options": ["nosniff"], "content-security-policy": ["default-src 'none'"], "x-ratelimit-limit": ["60"], "content-length": ["0"], "status": ["302 Found"], "x-frame-options": ["deny"], "x-served-by": ["8dd185e423974a7e13abbbe6e060031e"], "server": ["GitHub.com"], "access-control-allow-origin": ["*"], "strict-transport-security": ["max-age=31536000; includeSubdomains; preload"], "x-github-request-id": ["48A0C951:54E7:48B5311:55019319"], "date": ["Thu, 12 Mar 2015 13:22:33 GMT"], "access-control-expose-headers": ["ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval"], "x-ratelimit-remaining": ["58"], "content-type": ["text/html;charset=utf-8"], "x-ratelimit-reset": ["1426170017"]}, "body": {"base64_string": "", "encoding": "utf-8"}}, "recorded_at": "2015-03-12T13:22:33"}, {"request": {"uri": "https://s3.amazonaws.com/github-cloud/releases/3710711/365425c2-4e46-11e3-86fb-bb0d50a886e7.whl?response-content-disposition=attachment%3B%20filename%3Dgithub3.py-0.7.1-py2.py3-none-any.whl&response-content-type=application/octet-stream&AWSAccessKeyId=AKIAISTNZFOVBIJMK3TQ&Expires=1426166613&Signature=78anFgNgXLm3TIbo%2FbTEEk7m%2F34%3D", "method": "GET", "headers": {"Accept": ["application/octet-stream"], "Accept-Encoding": ["gzip, deflate"], "Connection": ["keep-alive"], "User-Agent": ["python-requests/2.5.3 CPython/2.7.9 Darwin/14.1.0"]}, "body": {"base64_string": "", "encoding": "utf-8"}}, "response": {"status": {"code": 200, "message": "OK"}, "url": "https://s3.amazonaws.com/github-cloud/releases/3710711/365425c2-4e46-11e3-86fb-bb0d50a886e7.whl?response-content-disposition=attachment%3B%20filename%3Dgithub3.py-0.7.1-py2.py3-none-any.whl&response-content-type=application/octet-stream&AWSAccessKeyId=AKIAISTNZFOVBIJMK3TQ&Expires=1426166613&Signature=78anFgNgXLm3TIbo%2FbTEEk7m%2F34%3D", "headers": {"accept-ranges": ["bytes"], "content-disposition": ["attachment; filename=github3.py-0.7.1-py2.py3-none-any.whl"], "x-amz-id-2": ["9+TuHhbd7y2BUJaEV+mFpaDgjl1g9uSAPiZxwc6b2cYydhlhZSyKSuB7PQyiPBPD"], "content-length": ["117140"], "x-amz-meta-surrogate-key": ["repository-3710711 user-240830"], "x-amz-request-id": ["4B4BFE6BF5135B8D"], "last-modified": ["Fri, 15 Nov 2013 22:35:23 GMT"], "x-amz-meta-surrogate-control": ["max-age=31557600"], "etag": ["\"6550854f02f7bf10b944070b84f38313\""], "date": ["Thu, 12 Mar 2015 13:22:35 GMT"], "cache-control": ["max-age=31557600"], "content-type": ["application/octet-stream"], "server": ["AmazonS3"]}, "body": {"base64_string": "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", "encoding": null}}, "recorded_at": "2015-03-12T13:22:34"}]} \ No newline at end of file diff --git a/tests/cassettes/test_x509_adapter_der.json b/tests/cassettes/test_x509_adapter_der.json new file mode 100644 index 0000000..0f4072a --- /dev/null +++ b/tests/cassettes/test_x509_adapter_der.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": ""}, "headers": {"User-Agent": ["python-requests/2.21.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"]}, "method": "GET", "uri": "https://pkiprojecttest01.dev.labs.internal/"}, "response": {"body": {"encoding": "ISO-8859-1", "base64_string": "H4sIAAAAAAAAA7NRdPF3DokMcFXIKMnNseOygVJJ+SmVdlxArqFdSGpxiY0+kAHkFoB5CsGlycmpxcU2+gUgQX2IYqAasBEAYvDs5FMAAAA=", "string": ""}, "headers": {"Server": ["nginx/1.10.3 (Ubuntu)"], "Date": ["Thu, 20 Dec 2018 20:02:30 GMT"], "Content-Type": ["text/html"], "Last-Modified": ["Mon, 19 Nov 2018 20:48:30 GMT"], "Transfer-Encoding": ["chunked"], "Connection": ["keep-alive"], "ETag": ["W/\"5bf3219e-53\""], "Content-Encoding": ["gzip"]}, "status": {"code": 200, "message": "OK"}, "url": "https://pkiprojecttest01.dev.labs.internal/"}, "recorded_at": "2018-12-20T20:02:30"}], "recorded_with": "betamax/0.8.1"} \ No newline at end of file diff --git a/tests/cassettes/test_x509_adapter_pem.json b/tests/cassettes/test_x509_adapter_pem.json new file mode 100644 index 0000000..0f4072a --- /dev/null +++ b/tests/cassettes/test_x509_adapter_pem.json @@ -0,0 +1 @@ +{"http_interactions": [{"request": {"body": {"encoding": "utf-8", "string": ""}, "headers": {"User-Agent": ["python-requests/2.21.0"], "Accept-Encoding": ["gzip, deflate"], "Accept": ["*/*"], "Connection": ["keep-alive"]}, "method": "GET", "uri": "https://pkiprojecttest01.dev.labs.internal/"}, "response": {"body": {"encoding": "ISO-8859-1", "base64_string": "H4sIAAAAAAAAA7NRdPF3DokMcFXIKMnNseOygVJJ+SmVdlxArqFdSGpxiY0+kAHkFoB5CsGlycmpxcU2+gUgQX2IYqAasBEAYvDs5FMAAAA=", "string": ""}, "headers": {"Server": ["nginx/1.10.3 (Ubuntu)"], "Date": ["Thu, 20 Dec 2018 20:02:30 GMT"], "Content-Type": ["text/html"], "Last-Modified": ["Mon, 19 Nov 2018 20:48:30 GMT"], "Transfer-Encoding": ["chunked"], "Connection": ["keep-alive"], "ETag": ["W/\"5bf3219e-53\""], "Content-Encoding": ["gzip"]}, "status": {"code": 200, "message": "OK"}, "url": "https://pkiprojecttest01.dev.labs.internal/"}, "recorded_at": "2018-12-20T20:02:30"}], "recorded_with": "betamax/0.8.1"} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..41ee1b1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +import os +import sys + +import betamax + +sys.path.insert(0, '.') + +placeholders = { + '': os.environ.get('IPADDR', '127.0.0.1'), +} + +with betamax.Betamax.configure() as config: + for placeholder, value in placeholders.items(): + config.define_cassette_placeholder(placeholder, value) diff --git a/tests/test_appengine_adapter.py b/tests/test_appengine_adapter.py new file mode 100644 index 0000000..3451b4c --- /dev/null +++ b/tests/test_appengine_adapter.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +"""Tests for the AppEngineAdapter.""" +import sys + +try: + from unittest import mock +except ImportError: + import mock +import pytest +import requests + +from requests_toolbelt import exceptions as exc + +REQUESTS_SUPPORTS_GAE = requests.__build__ >= 0x021000 + +if REQUESTS_SUPPORTS_GAE: + from requests.packages.urllib3.contrib import appengine as urllib3_appeng + from requests_toolbelt.adapters import appengine +else: + appengine = urllib3_appeng = None + + +@pytest.mark.skipif(sys.version_info >= (3,), + reason="App Engine doesn't support Python 3 (yet) and " + "urllib3's appengine contrib code is Python 2 " + "only. Until the latter changes, this test will " + "be skipped, unfortunately.") +@pytest.mark.skipif(not REQUESTS_SUPPORTS_GAE, + reason="Requires Requests v2.10.0 or later") +@mock.patch.object(urllib3_appeng, 'urlfetch') +def test_get(mock_urlfetch): + """Tests a simple requests.get() call. + + App Engine urlfetch docs: + https://cloud.google.com/appengine/docs/python/refdocs/google.appengine.api.urlfetch + """ + response = mock.Mock(status_code=200, content='asdf', headers={}) + mock_urlfetch.fetch = mock.Mock(return_value=response) + + session = requests.Session() + session.mount('http://', appengine.AppEngineAdapter()) + resp = session.get('http://url/', timeout=9, headers={'Foo': 'bar'}) + assert resp.status_code == 200 + assert resp.content == 'asdf' + + args, kwargs = mock_urlfetch.fetch.call_args + assert args == ('http://url/',) + assert kwargs['deadline'] == 9 + assert kwargs['headers']['Foo'] == 'bar' + + +@pytest.mark.skipif(sys.version_info >= (3,), + reason="App Engine doesn't support Python 3 (yet) and " + "urllib3's appengine contrib code is Python 2 " + "only. Until the latter changes, this test will " + "be skipped, unfortunately.") +@pytest.mark.skipif(not REQUESTS_SUPPORTS_GAE, + reason="Requires Requests v2.10.0 or later") +def test_appengine_monkeypatch(): + """Tests monkeypatching Requests adapters for AppEngine compatibility. + """ + adapter = requests.sessions.HTTPAdapter + + appengine.monkeypatch() + + assert requests.sessions.HTTPAdapter == appengine.AppEngineAdapter + assert requests.adapters.HTTPAdapter == appengine.AppEngineAdapter + + appengine.monkeypatch(validate_certificate=False) + + assert requests.sessions.HTTPAdapter == appengine.InsecureAppEngineAdapter + assert requests.adapters.HTTPAdapter == appengine.InsecureAppEngineAdapter + + requests.sessions.HTTPAdapter = adapter + requests.adapters.HTTPAdapter = adapter + + +@pytest.mark.skipif(sys.version_info >= (3,), + reason="App Engine doesn't support Python 3 (yet) and " + "urllib3's appengine contrib code is Python 2 " + "only. Until the latter changes, this test will " + "be skipped, unfortunately.") +@pytest.mark.skipif(not REQUESTS_SUPPORTS_GAE, + reason="Requires Requests v2.10.0 or later") +@mock.patch.object(urllib3_appeng, 'urlfetch') +def test_insecure_appengine_adapter(mock_urlfetch): + adapter = appengine.InsecureAppEngineAdapter() + + assert not adapter._validate_certificate + + with pytest.warns(exc.IgnoringGAECertificateValidation): + adapter = appengine.InsecureAppEngineAdapter(validate_certificate=True) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..e633aa6 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +import requests +import unittest +try: + from unittest import mock +except ImportError: + import mock + +from requests_toolbelt.auth.guess import GuessAuth, GuessProxyAuth +from . import get_betamax + + +class TestGuessAuth(unittest.TestCase): + def setUp(self): + self.session = requests.Session() + self.recorder = get_betamax(self.session) + + def cassette(self, name): + return self.recorder.use_cassette( + 'httpbin_guess_auth_' + name, + match_requests_on=['method', 'uri', 'digest-auth'] + ) + + def test_basic(self): + with self.cassette('basic'): + r = self.session.request( + 'GET', 'http://httpbin.org/basic-auth/user/passwd', + auth=GuessAuth('user', 'passwd')) + + assert r.json() == {'authenticated': True, 'user': 'user'} + + def test_digest(self): + with self.cassette('digest'): + r = self.session.request( + 'GET', 'http://httpbin.org/digest-auth/auth/user/passwd', + auth=GuessAuth('user', 'passwd')) + + assert r.json() == {'authenticated': True, 'user': 'user'} + + def test_no_auth(self): + with self.cassette('none'): + url = 'http://httpbin.org/get?a=1' + r = self.session.request('GET', url, + auth=GuessAuth('user', 'passwd')) + + j = r.json() + assert j['args'] == {'a': '1'} + assert j['url'] == url + assert 'user' not in r.text + assert 'passwd' not in r.text + + +class TestGuessProxyAuth(unittest.TestCase): + + @mock.patch('requests_toolbelt.auth.http_proxy_digest.HTTPProxyDigestAuth.handle_407') + def test_handle_407_header_digest(self, mock_handle_407): + r = requests.Response() + r.headers['Proxy-Authenticate'] = 'Digest nonce="d2b19757d3d656a283c99762cbd1097b", opaque="1c311ad1cc6e6183b83bc75f95a57893", realm="me@kennethreitz.com", qop=auth' + + guess_auth = GuessProxyAuth(None, None, "user", "passwd") + guess_auth.handle_407(r) + + mock_handle_407.assert_called_with(r) + + @mock.patch('requests.auth.HTTPProxyAuth.__call__') + @mock.patch('requests.cookies.extract_cookies_to_jar') + def test_handle_407_header_basic(self, extract_cookies_to_jar, proxy_auth_call): + req = mock.Mock() + r = mock.Mock() + r.headers = dict() + r.request.copy.return_value = req + + proxy_auth_call.return_value = requests.Response() + + kwargs = {} + r.headers['Proxy-Authenticate'] = 'Basic realm="Fake Realm"' + guess_auth = GuessProxyAuth(None, None, "user", "passwd") + guess_auth.handle_407(r, *kwargs) + + proxy_auth_call.assert_called_with(req) diff --git a/tests/test_auth_handler.py b/tests/test_auth_handler.py new file mode 100644 index 0000000..6ddade7 --- /dev/null +++ b/tests/test_auth_handler.py @@ -0,0 +1,58 @@ +import requests +from requests.auth import HTTPBasicAuth +from requests_toolbelt.auth.handler import AuthHandler +from requests_toolbelt.auth.handler import NullAuthStrategy + + +def test_turns_tuples_into_basic_auth(): + a = AuthHandler({'http://example.com': ('foo', 'bar')}) + strategy = a.get_strategy_for('http://example.com') + assert not isinstance(strategy, NullAuthStrategy) + assert isinstance(strategy, HTTPBasicAuth) + + +def test_uses_null_strategy_for_non_matching_domains(): + a = AuthHandler({'http://api.example.com': ('foo', 'bar')}) + strategy = a.get_strategy_for('http://example.com') + assert isinstance(strategy, NullAuthStrategy) + + +def test_normalizes_domain_keys(): + a = AuthHandler({'https://API.github.COM': ('foo', 'bar')}) + assert 'https://api.github.com' in a.strategies + assert 'https://API.github.COM' not in a.strategies + + +def test_can_add_new_strategies(): + a = AuthHandler({'https://example.com': ('foo', 'bar')}) + a.add_strategy('https://api.github.com', ('fiz', 'baz')) + assert isinstance( + a.get_strategy_for('https://api.github.com'), + HTTPBasicAuth + ) + + +def test_prepares_auth_correctly(): + # Set up our Session and AuthHandler + auth = AuthHandler({ + 'https://api.example.com': ('bar', 'baz'), + 'https://httpbin.org': ('biz', 'fiz'), + }) + s = requests.Session() + s.auth = auth + # Set up a valid GET request to https://api.example.com/users + r1 = requests.Request('GET', 'https://api.example.com/users') + p1 = s.prepare_request(r1) + assert p1.headers['Authorization'] == 'Basic YmFyOmJheg==' + + # Set up a valid POST request to https://httpbin.org/post + r2 = requests.Request('POST', 'https://httpbin.org/post', data='foo') + p2 = s.prepare_request(r2) + assert p2.headers['Authorization'] == 'Basic Yml6OmZpeg==' + + # Set up an *invalid* OPTIONS request to http://api.example.com + # NOTE(sigmavirus24): This is not because of the verb but instead because + # it is the wrong URI scheme. + r3 = requests.Request('OPTIONS', 'http://api.example.com/projects') + p3 = s.prepare_request(r3) + assert p3.headers.get('Authorization') is None diff --git a/tests/test_downloadutils.py b/tests/test_downloadutils.py new file mode 100644 index 0000000..5fa9fba --- /dev/null +++ b/tests/test_downloadutils.py @@ -0,0 +1,223 @@ +"""Tests for the utils module.""" +import io +import os +import os.path +import shutil +import tempfile + +import requests +from requests_toolbelt.downloadutils import stream +from requests_toolbelt.downloadutils import tee +try: + from unittest import mock +except ImportError: + import mock +import pytest + +from . import get_betamax + + +preserve_bytes = {'preserve_exact_body_bytes': True} + + +def test_get_download_file_path_uses_content_disposition(): + s = requests.Session() + recorder = get_betamax(s) + url = ('https://api.github.com/repos/sigmavirus24/github3.py/releases/' + 'assets/37944') + filename = 'github3.py-0.7.1-py2.py3-none-any.whl' + with recorder.use_cassette('stream_response_to_file', **preserve_bytes): + r = s.get(url, headers={'Accept': 'application/octet-stream'}) + path = stream.get_download_file_path(r, None) + r.close() + assert path == filename + +def test_get_download_file_path_directory(): + s = requests.Session() + recorder = get_betamax(s) + url = ('https://api.github.com/repos/sigmavirus24/github3.py/releases/' + 'assets/37944') + filename = 'github3.py-0.7.1-py2.py3-none-any.whl' + with recorder.use_cassette('stream_response_to_file', **preserve_bytes): + r = s.get(url, headers={'Accept': 'application/octet-stream'}) + path = stream.get_download_file_path(r, tempfile.tempdir) + r.close() + assert path == os.path.join(tempfile.tempdir, filename) + + +def test_get_download_file_path_specific_file(): + s = requests.Session() + recorder = get_betamax(s) + url = ('https://api.github.com/repos/sigmavirus24/github3.py/releases/' + 'assets/37944') + with recorder.use_cassette('stream_response_to_file', **preserve_bytes): + r = s.get(url, headers={'Accept': 'application/octet-stream'}) + path = stream.get_download_file_path(r, '/arbitrary/file.path') + r.close() + assert path == '/arbitrary/file.path' + + +def test_stream_response_to_file_uses_content_disposition(): + s = requests.Session() + recorder = get_betamax(s) + url = ('https://api.github.com/repos/sigmavirus24/github3.py/releases/' + 'assets/37944') + filename = 'github3.py-0.7.1-py2.py3-none-any.whl' + with recorder.use_cassette('stream_response_to_file', **preserve_bytes): + r = s.get(url, headers={'Accept': 'application/octet-stream'}, + stream=True) + stream.stream_response_to_file(r) + + assert os.path.exists(filename) + os.unlink(filename) + + +def test_stream_response_to_specific_filename(): + s = requests.Session() + recorder = get_betamax(s) + url = ('https://api.github.com/repos/sigmavirus24/github3.py/releases/' + 'assets/37944') + filename = 'github3.py.whl' + with recorder.use_cassette('stream_response_to_file', **preserve_bytes): + r = s.get(url, headers={'Accept': 'application/octet-stream'}, + stream=True) + stream.stream_response_to_file(r, path=filename) + + assert os.path.exists(filename) + os.unlink(filename) + + +def test_stream_response_to_directory(): + s = requests.Session() + recorder = get_betamax(s) + url = ('https://api.github.com/repos/sigmavirus24/github3.py/releases/' + 'assets/37944') + + td = tempfile.mkdtemp() + try: + filename = 'github3.py-0.7.1-py2.py3-none-any.whl' + expected_path = os.path.join(td, filename) + with recorder.use_cassette('stream_response_to_file', **preserve_bytes): + r = s.get(url, headers={'Accept': 'application/octet-stream'}, + stream=True) + stream.stream_response_to_file(r, path=td) + + assert os.path.exists(expected_path) + finally: + shutil.rmtree(td) + + +def test_stream_response_to_existing_file(): + s = requests.Session() + recorder = get_betamax(s) + url = ('https://api.github.com/repos/sigmavirus24/github3.py/releases/' + 'assets/37944') + filename = 'github3.py.whl' + with open(filename, 'w') as f_existing: + f_existing.write('test') + + with recorder.use_cassette('stream_response_to_file', **preserve_bytes): + r = s.get(url, headers={'Accept': 'application/octet-stream'}, + stream=True) + try: + stream.stream_response_to_file(r, path=filename) + except stream.exc.StreamingError as e: + assert str(e).startswith('File already exists:') + else: + assert False, "Should have raised a FileExistsError" + finally: + os.unlink(filename) + + +def test_stream_response_to_file_like_object(): + s = requests.Session() + recorder = get_betamax(s) + url = ('https://api.github.com/repos/sigmavirus24/github3.py/releases/' + 'assets/37944') + file_obj = io.BytesIO() + with recorder.use_cassette('stream_response_to_file', **preserve_bytes): + r = s.get(url, headers={'Accept': 'application/octet-stream'}, + stream=True) + stream.stream_response_to_file(r, path=file_obj) + + assert 0 < file_obj.tell() + + +def test_stream_response_to_file_chunksize(): + s = requests.Session() + recorder = get_betamax(s) + url = ('https://api.github.com/repos/sigmavirus24/github3.py/releases/' + 'assets/37944') + + class FileWrapper(io.BytesIO): + def __init__(self): + super(FileWrapper, self).__init__() + self.chunk_sizes = [] + + def write(self, data): + self.chunk_sizes.append(len(data)) + return super(FileWrapper, self).write(data) + + file_obj = FileWrapper() + + chunksize = 1231 + + with recorder.use_cassette('stream_response_to_file', **preserve_bytes): + r = s.get(url, headers={'Accept': 'application/octet-stream'}, + stream=True) + stream.stream_response_to_file(r, path=file_obj, chunksize=chunksize) + + assert 0 < file_obj.tell() + + assert len(file_obj.chunk_sizes) >= 1 + assert file_obj.chunk_sizes[0] == chunksize + + +@pytest.fixture +def streamed_response(chunks=None): + chunks = chunks or [b'chunk'] * 8 + response = mock.MagicMock() + response.raw.stream.return_value = chunks + return response + + +def test_tee(streamed_response): + response = streamed_response + expected_len = len('chunk') * 8 + fileobject = io.BytesIO() + assert expected_len == sum(len(c) for c in tee.tee(response, fileobject)) + assert fileobject.getvalue() == b'chunkchunkchunkchunkchunkchunkchunkchunk' + + +def test_tee_rejects_StringIO(): + fileobject = io.StringIO() + with pytest.raises(TypeError): + # The generator needs to be iterated over before the exception will be + # raised + sum(len(c) for c in tee.tee(None, fileobject)) + + +def test_tee_to_file(streamed_response): + response = streamed_response + expected_len = len('chunk') * 8 + assert expected_len == sum( + len(c) for c in tee.tee_to_file(response, 'tee.txt') + ) + assert os.path.exists('tee.txt') + os.remove('tee.txt') + + +def test_tee_to_bytearray(streamed_response): + response = streamed_response + arr = bytearray() + expected_arr = bytearray(b'chunk' * 8) + expected_len = len(expected_arr) + assert expected_len == sum( + len(c) for c in tee.tee_to_bytearray(response, arr) + ) + assert expected_arr == arr + + +def test_tee_to_bytearray_only_accepts_bytearrays(): + with pytest.raises(TypeError): + tee.tee_to_bytearray(None, object()) diff --git a/tests/test_dump.py b/tests/test_dump.py new file mode 100644 index 0000000..9ee2b45 --- /dev/null +++ b/tests/test_dump.py @@ -0,0 +1,405 @@ +"""Collection of tests for utils.dump. + +The dump utility module only has two public attributes: + +- dump_response +- dump_all + +This module, however, tests many of the private implementation details since +those public functions just wrap them and testing the public functions will be +very complex and high-level. +""" +from requests_toolbelt._compat import HTTPHeaderDict +from requests_toolbelt.utils import dump + +try: + from unittest import mock +except ImportError: + import mock +import pytest +import requests + +from . import get_betamax + +HTTP_1_1 = 11 +HTTP_1_0 = 10 +HTTP_0_9 = 9 +HTTP_UNKNOWN = 5000 + + +class TestSimplePrivateFunctions(object): + + """Excercise simple private functions in one logical place.""" + + def test_coerce_to_bytes_skips_byte_strings(self): + """Show that _coerce_to_bytes skips bytes input.""" + bytestr = b'some bytes' + assert dump._coerce_to_bytes(bytestr) is bytestr + + def test_coerce_to_bytes_converts_text(self): + """Show that _coerce_to_bytes handles text input.""" + bytestr = b'some bytes' + text = bytestr.decode('utf-8') + assert dump._coerce_to_bytes(text) == bytestr + + def test_format_header(self): + """Prove that _format_header correctly formats bytes input.""" + header = b'Connection' + value = b'close' + expected = b'Connection: close\r\n' + assert dump._format_header(header, value) == expected + + def test_format_header_handles_unicode(self): + """Prove that _format_header correctly formats text input.""" + header = b'Connection'.decode('utf-8') + value = b'close'.decode('utf-8') + expected = b'Connection: close\r\n' + assert dump._format_header(header, value) == expected + + def test_build_request_path(self): + """Show we get the right request path for a normal request.""" + path, _ = dump._build_request_path( + 'https://example.com/foo/bar', {} + ) + assert path == b'/foo/bar' + + def test_build_request_path_with_query_string(self): + """Show we include query strings appropriately.""" + path, _ = dump._build_request_path( + 'https://example.com/foo/bar?query=data', {} + ) + assert path == b'/foo/bar?query=data' + + def test_build_request_path_with_proxy_info(self): + """Show that we defer to the proxy request_path info.""" + path, _ = dump._build_request_path( + 'https://example.com/', { + 'request_path': b'https://example.com/test' + } + ) + assert path == b'https://example.com/test' + + +class RequestResponseMixin(object): + + """Mix-in for test classes needing mocked requests and responses.""" + + response_spec = [ + 'connection', + 'content', + 'raw', + 'reason', + 'request', + 'url', + ] + + request_spec = [ + 'body', + 'headers', + 'method', + 'url', + ] + + httpresponse_spec = [ + 'headers', + 'reason', + 'status', + 'version', + ] + + adapter_spec = [ + 'proxy_manager', + ] + + @pytest.fixture(autouse=True) + def set_up(self): + """xUnit style autoused fixture creating mocks.""" + self.response = mock.Mock(spec=self.response_spec) + self.request = mock.Mock(spec=self.request_spec) + self.httpresponse = mock.Mock(spec=self.httpresponse_spec) + self.adapter = mock.Mock(spec=self.adapter_spec) + + self.response.connection = self.adapter + self.response.request = self.request + self.response.raw = self.httpresponse + + def configure_response(self, content=b'', proxy_manager=None, url=None, + reason=b''): + """Helper function to configure a mocked response.""" + self.adapter.proxy_manager = proxy_manager or {} + self.response.content = content + self.response.url = url + self.response.reason = reason + + def configure_request(self, body=b'', headers=None, method=None, + url=None): + """Helper function to configure a mocked request.""" + self.request.body = body + self.request.headers = headers or {} + self.request.method = method + self.request.url = url + + def configure_httpresponse(self, headers=None, reason=b'', status=200, + version=HTTP_1_1): + """Helper function to configure a mocked urllib3 response.""" + self.httpresponse.headers = HTTPHeaderDict(headers or {}) + self.httpresponse.reason = reason + self.httpresponse.status = status + self.httpresponse.version = version + + +class TestResponsePrivateFunctions(RequestResponseMixin): + + """Excercise private functions using responses.""" + + def test_get_proxy_information_sans_proxy(self): + """Show no information is returned when not using a proxy.""" + self.configure_response() + + assert dump._get_proxy_information(self.response) is None + + def test_get_proxy_information_with_proxy_over_http(self): + """Show only the request path is returned for HTTP requests. + + Using HTTP over a proxy doesn't alter anything except the request path + of the request. The method doesn't change a dictionary with the + request_path is the only thing that should be returned. + """ + self.configure_response( + proxy_manager={'http://': 'http://local.proxy:3939'}, + ) + self.configure_request( + url='http://example.com', + method='GET', + ) + + assert dump._get_proxy_information(self.response) == { + 'request_path': 'http://example.com' + } + + def test_get_proxy_information_with_proxy_over_https(self): + """Show that the request path and method are returned for HTTPS reqs. + + Using HTTPS over a proxy changes the method used and the request path. + """ + self.configure_response( + proxy_manager={'http://': 'http://local.proxy:3939'}, + ) + self.configure_request( + url='https://example.com', + method='GET', + ) + + assert dump._get_proxy_information(self.response) == { + 'method': 'CONNECT', + 'request_path': 'https://example.com' + } + + def test_dump_request_data(self): + """Build up the request data into a bytearray.""" + self.configure_request( + url='http://example.com/', + method='GET', + ) + + array = bytearray() + prefixes = dump.PrefixSettings('request:', 'response:') + dump._dump_request_data( + request=self.request, + prefixes=prefixes, + bytearr=array, + proxy_info={}, + ) + + assert b'request:GET / HTTP/1.1\r\n' in array + assert b'request:Host: example.com\r\n' in array + + def test_dump_non_string_request_data(self): + """Build up the request data into a bytearray.""" + self.configure_request( + url='http://example.com/', + method='POST', + body=1 + ) + + array = bytearray() + prefixes = dump.PrefixSettings('request:', 'response:') + dump._dump_request_data( + request=self.request, + prefixes=prefixes, + bytearr=array, + proxy_info={}, + ) + assert b'request:POST / HTTP/1.1\r\n' in array + assert b'request:Host: example.com\r\n' in array + assert b'<< Request body is not a string-like type >>\r\n' in array + + def test_dump_request_data_with_proxy_info(self): + """Build up the request data into a bytearray.""" + self.configure_request( + url='http://example.com/', + method='GET', + ) + + array = bytearray() + prefixes = dump.PrefixSettings('request:', 'response:') + dump._dump_request_data( + request=self.request, + prefixes=prefixes, + bytearr=array, + proxy_info={ + 'request_path': b'fake-request-path', + 'method': b'CONNECT', + }, + ) + + assert b'request:CONNECT fake-request-path HTTP/1.1\r\n' in array + assert b'request:Host: example.com\r\n' in array + + def test_dump_response_data(self): + """Build up the response data into a bytearray.""" + self.configure_response( + url='https://example.com/redirected', + content=b'foobarbogus', + reason=b'OK', + ) + self.configure_httpresponse( + headers={'Content-Type': 'application/json'}, + reason=b'OK', + status=201, + ) + + array = bytearray() + prefixes = dump.PrefixSettings('request:', 'response:') + dump._dump_response_data( + response=self.response, + prefixes=prefixes, + bytearr=array, + ) + + assert b'response:HTTP/1.1 201 OK\r\n' in array + assert b'response:Content-Type: application/json\r\n' in array + + def test_dump_response_data_with_older_http_version(self): + """Build up the response data into a bytearray.""" + self.configure_response( + url='https://example.com/redirected', + content=b'foobarbogus', + reason=b'OK', + ) + self.configure_httpresponse( + headers={'Content-Type': 'application/json'}, + reason=b'OK', + status=201, + version=HTTP_0_9, + ) + + array = bytearray() + prefixes = dump.PrefixSettings('request:', 'response:') + dump._dump_response_data( + response=self.response, + prefixes=prefixes, + bytearr=array, + ) + + assert b'response:HTTP/0.9 201 OK\r\n' in array + assert b'response:Content-Type: application/json\r\n' in array + + def test_dump_response_data_with_unknown_http_version(self): + """Build up the response data into a bytearray.""" + self.configure_response( + url='https://example.com/redirected', + content=b'foobarbogus', + reason=b'OK', + ) + self.configure_httpresponse( + headers={'Content-Type': 'application/json'}, + reason=b'OK', + status=201, + version=HTTP_UNKNOWN, + ) + + array = bytearray() + prefixes = dump.PrefixSettings('request:', 'response:') + dump._dump_response_data( + response=self.response, + prefixes=prefixes, + bytearr=array, + ) + + assert b'response:HTTP/? 201 OK\r\n' in array + assert b'response:Content-Type: application/json\r\n' in array + + +class TestResponsePublicFunctions(RequestResponseMixin): + + """Excercise public functions using responses.""" + + def test_dump_response_fails_without_request(self): + """Show that a response without a request raises a ValueError.""" + del self.response.request + assert hasattr(self.response, 'request') is False + + with pytest.raises(ValueError): + dump.dump_response(self.response) + + def test_dump_response_uses_provided_bytearray(self): + """Show that users providing bytearrays receive those back.""" + self.configure_request( + url='http://example.com/', + method='GET', + ) + self.configure_response( + url='https://example.com/redirected', + content=b'foobarbogus', + reason=b'OK', + ) + self.configure_httpresponse( + headers={'Content-Type': 'application/json'}, + reason=b'OK', + status=201, + ) + arr = bytearray() + + retarr = dump.dump_response(self.response, data_array=arr) + assert retarr is arr + + +class TestDumpRealResponses(object): + + """Exercise dump utilities against real data.""" + + def test_dump_response(self): + session = requests.Session() + recorder = get_betamax(session) + with recorder.use_cassette('simple_get_request'): + response = session.get('https://httpbin.org/get') + + arr = dump.dump_response(response) + assert b'< GET /get HTTP/1.1\r\n' in arr + assert b'< Host: httpbin.org\r\n' in arr + # NOTE(sigmavirus24): The ? below is only because Betamax doesn't + # preserve which HTTP version the server reports as supporting. + # When not using Betamax, there should be a different version + # reported. + assert b'> HTTP/? 200 OK\r\n' in arr + assert b'> Content-Type: application/json\r\n' in arr + + def test_dump_all(self): + session = requests.Session() + recorder = get_betamax(session) + with recorder.use_cassette('redirect_request_for_dump_all'): + response = session.get('https://httpbin.org/redirect/5') + + arr = dump.dump_all(response) + assert b'< GET /redirect/5 HTTP/1.1\r\n' in arr + assert b'> Location: /relative-redirect/4\r\n' in arr + assert b'< GET /relative-redirect/4 HTTP/1.1\r\n' in arr + assert b'> Location: /relative-redirect/3\r\n' in arr + assert b'< GET /relative-redirect/3 HTTP/1.1\r\n' in arr + assert b'> Location: /relative-redirect/2\r\n' in arr + assert b'< GET /relative-redirect/2 HTTP/1.1\r\n' in arr + assert b'> Location: /relative-redirect/1\r\n' in arr + assert b'< GET /relative-redirect/1 HTTP/1.1\r\n' in arr + assert b'> Location: /get\r\n' in arr + assert b'< GET /get HTTP/1.1\r\n' in arr diff --git a/tests/test_fingerprintadapter.py b/tests/test_fingerprintadapter.py new file mode 100644 index 0000000..1951160 --- /dev/null +++ b/tests/test_fingerprintadapter.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +import requests +import unittest + +from requests_toolbelt.adapters.fingerprint import FingerprintAdapter +from . import get_betamax + + +class TestFingerprintAdapter(unittest.TestCase): + HTTP2BIN_FINGERPRINT = 'abf8683eeba8521ad2e8dc48e92a1cbea3ff8608f1417948fdad75d7b50eb264' + + def setUp(self): + self.session = requests.Session() + self.session.mount('https://http2bin.org', FingerprintAdapter(self.HTTP2BIN_FINGERPRINT)) + self.recorder = get_betamax(self.session) + + def test_fingerprint(self): + with self.recorder.use_cassette('http2bin_fingerprint'): + r = self.session.get('https://http2bin.org/get') + assert r.status_code == 200 diff --git a/tests/test_forgetfulcookiejar.py b/tests/test_forgetfulcookiejar.py new file mode 100644 index 0000000..e9d07ed --- /dev/null +++ b/tests/test_forgetfulcookiejar.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +import requests +import unittest + +from requests_toolbelt.cookies.forgetful import ForgetfulCookieJar +from . import get_betamax + + +class TestForgetfulCookieJar(unittest.TestCase): + + def setUp(self): + self.session = requests.Session() + self.session.cookies = ForgetfulCookieJar() + self.recorder = get_betamax(self.session) + + def test_cookies_are_ignored(self): + with self.recorder.use_cassette('http2bin_cookies'): + url = 'https://httpbin.org/cookies/set' + cookies = { + 'cookie0': 'value0', + } + r = self.session.request( + 'GET', url, + params=cookies + ) + assert 'cookie0' not in self.session.cookies diff --git a/tests/test_formdata.py b/tests/test_formdata.py new file mode 100644 index 0000000..dd8bf03 --- /dev/null +++ b/tests/test_formdata.py @@ -0,0 +1,76 @@ +"""Test module for requests_toolbelt.utils.formdata.""" +try: + from urllib.parse import parse_qs +except ImportError: + from urlparse import parse_qs + +from requests_toolbelt.utils.formdata import urlencode + +import pytest + +dict_query = { + 'first_nested': { + 'second_nested': { + 'third_nested': { + 'fourth0': 'fourth_value0', + 'fourth1': 'fourth_value1', + }, + 'third0': 'third_value0', + }, + 'second0': 'second_value0', + }, + 'outter': 'outter_value', +} + +list_query = [ + ('first_nested', [ + ('second_nested', [ + ('third_nested', [ + ('fourth0', 'fourth_value0'), + ('fourth1', 'fourth_value1'), + ]), + ('third0', 'third_value0'), + ]), + ('second0', 'second_value0'), + ]), + ('outter', 'outter_value'), +] + +mixed_dict_query = { + 'first_nested': { + 'second_nested': [ + ('third_nested', { + 'fourth0': 'fourth_value0', + 'fourth1': 'fourth_value1', + }), + ('third0', 'third_value0'), + ], + 'second0': 'second_value0', + }, + 'outter': 'outter_value', +} + +expected_parsed_query = { + 'first_nested[second0]': ['second_value0'], + 'first_nested[second_nested][third0]': ['third_value0'], + 'first_nested[second_nested][third_nested][fourth0]': ['fourth_value0'], + 'first_nested[second_nested][third_nested][fourth1]': ['fourth_value1'], + 'outter': ['outter_value'], +} + + +@pytest.mark.parametrize("query", [dict_query, list_query, mixed_dict_query]) +def test_urlencode_flattens_nested_structures(query): + """Show that when parsed, the structure is conveniently flat.""" + parsed = parse_qs(urlencode(query)) + + assert parsed == expected_parsed_query + + +def test_urlencode_catches_invalid_input(): + """Show that queries are loosely validated.""" + with pytest.raises(ValueError): + urlencode(['fo']) + + with pytest.raises(ValueError): + urlencode([('foo', 'bar', 'bogus')]) diff --git a/tests/test_host_header_ssl_adapter.py b/tests/test_host_header_ssl_adapter.py new file mode 100644 index 0000000..d86378e --- /dev/null +++ b/tests/test_host_header_ssl_adapter.py @@ -0,0 +1,48 @@ +import pytest +import requests + +from requests_toolbelt.adapters import host_header_ssl as hhssl + + +@pytest.fixture +def session(): + """Create a session with our adapter mounted.""" + session = requests.Session() + session.mount('https://', hhssl.HostHeaderSSLAdapter()) + + +@pytest.mark.skip +class TestHostHeaderSSLAdapter(object): + """Tests for our HostHeaderSNIAdapter.""" + + def test_ssladapter(self, session): + # normal mode + r = session.get('https://example.org') + assert r.status_code == 200 + + # accessing IP address directly + r = session.get('https://93.184.216.34', + headers={"Host": "example.org"}) + assert r.status_code == 200 + + # vHost + r = session.get('https://93.184.216.34', + headers={'Host': 'example.com'}) + assert r.status_code == 200 + + def test_stream(self): + self.session.get('https://54.175.219.8/stream/20', + headers={'Host': 'httpbin.org'}, + stream=True) + + def test_case_insensitive_header(self): + r = self.session.get('https://93.184.216.34', + headers={'hOSt': 'example.org'}) + assert r.status_code == 200 + + def test_plain_requests(self): + # test whether the reason for this adapter remains + # (may be implemented into requests in the future) + with pytest.raises(requests.exceptions.SSLError): + requests.get(url='https://93.184.216.34', + headers={'Host': 'example.org'}) diff --git a/tests/test_multipart_decoder.py b/tests/test_multipart_decoder.py new file mode 100644 index 0000000..e922918 --- /dev/null +++ b/tests/test_multipart_decoder.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +import io +import sys +import unittest +try: + from unittest import mock +except ImportError: + import mock +import pytest +import requests +from requests_toolbelt.multipart.decoder import BodyPart +from requests_toolbelt.multipart.decoder import ( + ImproperBodyPartContentException +) +from requests_toolbelt.multipart.decoder import MultipartDecoder +from requests_toolbelt.multipart.decoder import ( + NonMultipartContentTypeException +) +from requests_toolbelt.multipart.encoder import encode_with +from requests_toolbelt.multipart.encoder import MultipartEncoder + + +class TestBodyPart(unittest.TestCase): + @staticmethod + def u(content): + major = sys.version_info[0] + if major == 3: + return content + else: + return unicode(content.replace(r'\\', r'\\\\'), 'unicode_escape') + + @staticmethod + def bodypart_bytes_from_headers_and_values(headers, value, encoding): + return b'\r\n\r\n'.join( + [ + b'\r\n'.join( + [ + b': '.join([encode_with(i, encoding) for i in h]) + for h in headers + ] + ), + encode_with(value, encoding) + ] + ) + + def setUp(self): + self.header_1 = (TestBodyPart.u('Snowman'), TestBodyPart.u('☃')) + self.value_1 = TestBodyPart.u('©') + self.part_1 = BodyPart( + TestBodyPart.bodypart_bytes_from_headers_and_values( + (self.header_1,), self.value_1, 'utf-8' + ), + 'utf-8' + ) + self.part_2 = BodyPart( + TestBodyPart.bodypart_bytes_from_headers_and_values( + [], self.value_1, 'utf-16' + ), + 'utf-16' + ) + + def test_equality_content_should_be_equal(self): + part_3 = BodyPart( + TestBodyPart.bodypart_bytes_from_headers_and_values( + [], self.value_1, 'utf-8' + ), + 'utf-8' + ) + assert self.part_1.content == part_3.content + + def test_equality_content_equals_bytes(self): + assert self.part_1.content == encode_with(self.value_1, 'utf-8') + + def test_equality_content_should_not_be_equal(self): + assert self.part_1.content != self.part_2.content + + def test_equality_content_does_not_equal_bytes(self): + assert self.part_1.content != encode_with(self.value_1, 'latin-1') + + def test_changing_encoding_changes_text(self): + part_2_orig_text = self.part_2.text + self.part_2.encoding = 'latin-1' + assert self.part_2.text != part_2_orig_text + + def test_text_should_be_equal(self): + assert self.part_1.text == self.part_2.text + + def test_no_headers(self): + sample_1 = b'\r\n\r\nNo headers\r\nTwo lines' + part_3 = BodyPart(sample_1, 'utf-8') + assert len(part_3.headers) == 0 + assert part_3.content == b'No headers\r\nTwo lines' + + def test_no_crlf_crlf_in_content(self): + content = b'no CRLF CRLF here!\r\n' + with pytest.raises(ImproperBodyPartContentException): + BodyPart(content, 'utf-8') + + +class TestMultipartDecoder(unittest.TestCase): + def setUp(self): + self.sample_1 = ( + ('field 1', 'value 1'), + ('field 2', 'value 2'), + ('field 3', 'value 3'), + ('field 4', 'value 4'), + ) + self.boundary = 'test boundary' + self.encoded_1 = MultipartEncoder(self.sample_1, self.boundary) + self.decoded_1 = MultipartDecoder( + self.encoded_1.to_string(), + self.encoded_1.content_type + ) + + def test_non_multipart_response_fails(self): + jpeg_response = mock.NonCallableMagicMock(spec=requests.Response) + jpeg_response.headers = {'content-type': 'image/jpeg'} + with pytest.raises(NonMultipartContentTypeException): + MultipartDecoder.from_response(jpeg_response) + + def test_length_of_parts(self): + assert len(self.sample_1) == len(self.decoded_1.parts) + + def test_content_of_parts(self): + def parts_equal(part, sample): + return part.content == encode_with(sample[1], 'utf-8') + + parts_iter = zip(self.decoded_1.parts, self.sample_1) + assert all(parts_equal(part, sample) for part, sample in parts_iter) + + def test_header_of_parts(self): + def parts_header_equal(part, sample): + return part.headers[b'Content-Disposition'] == encode_with( + 'form-data; name="{}"'.format(sample[0]), 'utf-8' + ) + + parts_iter = zip(self.decoded_1.parts, self.sample_1) + assert all( + parts_header_equal(part, sample) + for part, sample in parts_iter + ) + + def test_from_response(self): + response = mock.NonCallableMagicMock(spec=requests.Response) + response.headers = { + 'content-type': 'multipart/related; boundary="samp1"' + } + cnt = io.BytesIO() + cnt.write(b'\r\n--samp1\r\n') + cnt.write(b'Header-1: Header-Value-1\r\n') + cnt.write(b'Header-2: Header-Value-2\r\n') + cnt.write(b'\r\n') + cnt.write(b'Body 1, Line 1\r\n') + cnt.write(b'Body 1, Line 2\r\n') + cnt.write(b'--samp1\r\n') + cnt.write(b'\r\n') + cnt.write(b'Body 2, Line 1\r\n') + cnt.write(b'--samp1--\r\n') + response.content = cnt.getvalue() + decoder_2 = MultipartDecoder.from_response(response) + assert decoder_2.content_type == response.headers['content-type'] + assert ( + decoder_2.parts[0].content == b'Body 1, Line 1\r\nBody 1, Line 2' + ) + assert decoder_2.parts[0].headers[b'Header-1'] == b'Header-Value-1' + assert len(decoder_2.parts[1].headers) == 0 + assert decoder_2.parts[1].content == b'Body 2, Line 1' + + def test_from_responsecaplarge(self): + response = mock.NonCallableMagicMock(spec=requests.Response) + response.headers = { + 'content-type': 'Multipart/Related; boundary="samp1"' + } + cnt = io.BytesIO() + cnt.write(b'\r\n--samp1\r\n') + cnt.write(b'Header-1: Header-Value-1\r\n') + cnt.write(b'Header-2: Header-Value-2\r\n') + cnt.write(b'\r\n') + cnt.write(b'Body 1, Line 1\r\n') + cnt.write(b'Body 1, Line 2\r\n') + cnt.write(b'--samp1\r\n') + cnt.write(b'\r\n') + cnt.write(b'Body 2, Line 1\r\n') + cnt.write(b'--samp1--\r\n') + response.content = cnt.getvalue() + decoder_2 = MultipartDecoder.from_response(response) + assert decoder_2.content_type == response.headers['content-type'] + assert ( + decoder_2.parts[0].content == b'Body 1, Line 1\r\nBody 1, Line 2' + ) + assert decoder_2.parts[0].headers[b'Header-1'] == b'Header-Value-1' + assert len(decoder_2.parts[1].headers) == 0 + assert decoder_2.parts[1].content == b'Body 2, Line 1' diff --git a/tests/test_multipart_encoder.py b/tests/test_multipart_encoder.py new file mode 100644 index 0000000..bf2539e --- /dev/null +++ b/tests/test_multipart_encoder.py @@ -0,0 +1,326 @@ +# -*- coding: utf-8 -*- +import unittest +import io + +import requests + +import pytest +from requests_toolbelt.multipart.encoder import ( + CustomBytesIO, MultipartEncoder, FileFromURLWrapper, FileNotSupportedError) +from requests_toolbelt._compat import filepost +from . import get_betamax + + +preserve_bytes = {'preserve_exact_body_bytes': True} + + +class LargeFileMock(object): + def __init__(self): + # Let's keep track of how many bytes we've given + self.bytes_read = 0 + # Our limit (1GB) + self.bytes_max = 1024 * 1024 * 1024 + # Fake name + self.name = 'fake_name.py' + # Create a fileno attribute + self.fileno = None + + def __len__(self): + return self.bytes_max + + def read(self, size=None): + if self.bytes_read >= self.bytes_max: + return b'' + + if size is None: + length = self.bytes_max - self.bytes_read + else: + length = size + + length = int(length) + length = min([length, self.bytes_max - self.bytes_read]) + + self.bytes_read += length + + return b'a' * length + + def tell(self): + return self.bytes_read + + +class TestCustomBytesIO(unittest.TestCase): + def setUp(self): + self.instance = CustomBytesIO() + + def test_writable(self): + assert hasattr(self.instance, 'write') + assert self.instance.write(b'example') == 7 + + def test_readable(self): + assert hasattr(self.instance, 'read') + assert self.instance.read() == b'' + assert self.instance.read(10) == b'' + + def test_can_read_after_writing_to(self): + self.instance.write(b'example text') + self.instance.read() == b'example text' + + def test_can_read_some_after_writing_to(self): + self.instance.write(b'example text') + self.instance.read(6) == b'exampl' + + def test_can_get_length(self): + self.instance.write(b'example') + self.instance.seek(0, 0) + assert self.instance.len == 7 + + def test_truncates_intelligently(self): + self.instance.write(b'abcdefghijklmnopqrstuvwxyzabcd') # 30 bytes + assert self.instance.tell() == 30 + self.instance.seek(-10, 2) + self.instance.smart_truncate() + assert self.instance.len == 10 + assert self.instance.read() == b'uvwxyzabcd' + assert self.instance.tell() == 10 + + def test_accepts_encoded_strings_with_unicode(self): + """Accepts a string with encoded unicode characters.""" + s = b'this is a unicode string: \xc3\xa9 \xc3\xa1 \xc7\xab \xc3\xb3' + self.instance = CustomBytesIO(s) + assert self.instance.read() == s + + +class TestFileFromURLWrapper(unittest.TestCase): + def setUp(self): + s = requests.Session() + self.recorder = get_betamax(s) + + @pytest.mark.xfail + def test_read_file(self): + url = ('https://stxnext.com/static/img/logo.830ebe551641.svg') + with self.recorder.use_cassette( + 'file_for_download', **preserve_bytes): + self.instance = FileFromURLWrapper(url) + assert self.instance.len == 5177 + chunk = self.instance.read(20) + assert chunk == b' 0 + + def test_accepts_custom_content_type(self): + """Verify that the Encoder handles custom content-types. + + See https://github.com/requests/toolbelt/issues/52 + """ + fields = [ + (b'test'.decode('utf-8'), (b'filename'.decode('utf-8'), + b'filecontent', + b'application/json'.decode('utf-8'))) + ] + m = MultipartEncoder(fields=fields) + output = m.read().decode('utf-8') + assert output.index('Content-Type: application/json\r\n') > 0 + + def test_accepts_custom_headers(self): + """Verify that the Encoder handles custom headers. + + See https://github.com/requests/toolbelt/issues/52 + """ + fields = [ + (b'test'.decode('utf-8'), (b'filename'.decode('utf-8'), + b'filecontent', + b'application/json'.decode('utf-8'), + {'X-My-Header': 'my-value'})) + ] + m = MultipartEncoder(fields=fields) + output = m.read().decode('utf-8') + assert output.index('X-My-Header: my-value\r\n') > 0 + + def test_no_parts(self): + fields = [] + boundary = '--90967316f8404798963cce746a4f4ef9' + m = MultipartEncoder(fields=fields, boundary=boundary) + output = m.read().decode('utf-8') + assert output == '----90967316f8404798963cce746a4f4ef9--\r\n' + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_multipart_monitor.py b/tests/test_multipart_monitor.py new file mode 100644 index 0000000..a37f95f --- /dev/null +++ b/tests/test_multipart_monitor.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +import math +import unittest +from requests_toolbelt.multipart.encoder import ( + IDENTITY, MultipartEncoder, MultipartEncoderMonitor + ) + + +class TestMultipartEncoderMonitor(unittest.TestCase): + def setUp(self): + self.fields = {'a': 'b'} + self.boundary = 'thisisaboundary' + self.encoder = MultipartEncoder(self.fields, self.boundary) + self.monitor = MultipartEncoderMonitor(self.encoder) + + def test_content_type(self): + assert self.monitor.content_type == self.encoder.content_type + + def test_length(self): + assert self.encoder.len == self.monitor.len + + def test_read(self): + new_encoder = MultipartEncoder(self.fields, self.boundary) + assert new_encoder.read() == self.monitor.read() + + def test_callback_called_when_reading_everything(self): + callback = Callback(self.monitor) + self.monitor.callback = callback + self.monitor.read() + assert callback.called == 1 + + def test_callback(self): + callback = Callback(self.monitor) + self.monitor.callback = callback + chunk_size = int(math.ceil(self.encoder.len / 4.0)) + while self.monitor.read(chunk_size): + pass + assert callback.called == 5 + + def test_bytes_read(self): + bytes_to_read = self.encoder.len + self.monitor.read() + assert self.monitor.bytes_read == bytes_to_read + + def test_default_callable_is_the_identity(self): + assert self.monitor.callback == IDENTITY + assert IDENTITY(1) == 1 + + def test_from_fields(self): + monitor = MultipartEncoderMonitor.from_fields( + self.fields, self.boundary + ) + assert isinstance(monitor, MultipartEncoderMonitor) + assert isinstance(monitor.encoder, MultipartEncoder) + assert monitor.encoder.boundary_value == self.boundary + + +class Callback(object): + def __init__(self, monitor): + self.called = 0 + self.monitor = monitor + + def __call__(self, monitor): + self.called += 1 + assert monitor == self.monitor diff --git a/tests/test_proxy_digest_auth.py b/tests/test_proxy_digest_auth.py new file mode 100644 index 0000000..1ba9a71 --- /dev/null +++ b/tests/test_proxy_digest_auth.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""Test proxy digest authentication.""" + +import unittest +try: + from unittest import mock +except ImportError: + import mock + +import requests +from requests_toolbelt.auth import http_proxy_digest + + +class TestProxyDigestAuth(unittest.TestCase): + """Tests for the ProxyDigestAuth class.""" + + def setUp(self): + """Set up variables for each test.""" + self.username = "username" + self.password = "password" + self.auth = http_proxy_digest.HTTPProxyDigestAuth( + self.username, self.password + ) + self.prepared_request = requests.Request( + 'GET', + 'http://host.org/index.html' + ).prepare() + + def test_with_existing_nonce(self): + """Test if it will generate Proxy-Auth header when nonce present. + + Digest authentication's correctness will not be tested here. + """ + self.auth.last_nonce = "bH3FVAAAAAAg74rL3X8AAI3CyBAAAAAA" + self.auth.chal = { + 'nonce': self.auth.last_nonce, + 'realm': 'testrealm@host.org', + 'qop': 'auth' + } + + # prepared_request headers should be clear before calling auth + assert self.prepared_request.headers.get('Proxy-Authorization') is None + self.auth(self.prepared_request) + assert self.prepared_request.headers['Proxy-Authorization'] is not None + + def test_no_challenge(self): + """Test that a response containing no auth challenge is left alone.""" + connection = MockConnection() + first_response = connection.make_response(self.prepared_request) + first_response.status_code = 404 + + assert self.auth.last_nonce == '' + final_response = self.auth.handle_407(first_response) + headers = final_response.request.headers + assert self.auth.last_nonce == '' + assert first_response is final_response + assert headers.get('Proxy-Authorization') is None + + def test_digest_challenge(self): + """Test a response with a digest auth challenge causes a new request. + + This ensures that the auth class generates a new request with a + Proxy-Authorization header. + + Digest authentication's correctness will not be tested here. + """ + connection = MockConnection() + first_response = connection.make_response(self.prepared_request) + first_response.status_code = 407 + first_response.headers['Proxy-Authenticate'] = ( + 'Digest' + ' realm="Fake Realm", nonce="oS6WVgAAAABw698CAAAAAHAk/HUAAAAA",' + ' qop="auth", stale=false' + ) + + assert self.auth.last_nonce == '' + final_response = self.auth.handle_407(first_response) + headers = final_response.request.headers + assert self.auth.last_nonce != '' + assert first_response is not final_response + assert headers.get('Proxy-Authorization') is not None + + def test_ntlm_challenge(self): + """Test a response without a Digest auth challenge is left alone.""" + connection = MockConnection() + first_response = connection.make_response(self.prepared_request) + first_response.status_code = 407 + first_response.headers['Proxy-Authenticate'] = 'NTLM' + + assert self.auth.last_nonce == '' + final_response = self.auth.handle_407(first_response) + headers = final_response.request.headers + assert self.auth.last_nonce == '' + assert first_response is final_response + assert headers.get('Proxy-Authorization') is None + + +class MockConnection(object): + """Fake connection object.""" + + def send(self, request, **kwargs): + """Mock out the send method.""" + return self.make_response(request) + + def make_response(self, request): + """Make a response for us based on the request.""" + response = requests.Response() + response.status_code = 200 + response.request = request + response.raw = mock.MagicMock() + response.connection = self + return response + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sessions.py b/tests/test_sessions.py new file mode 100644 index 0000000..e375578 --- /dev/null +++ b/tests/test_sessions.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +import unittest +import pytest + +from requests_toolbelt import sessions +from requests import Request +from . import get_betamax + + +class TestBasedSession(unittest.TestCase): + def test_request_with_base(self): + session = sessions.BaseUrlSession('https://httpbin.org/') + recorder = get_betamax(session) + with recorder.use_cassette('simple_get_request'): + response = session.get('/get') + response.raise_for_status() + + def test_request_without_base(self): + session = sessions.BaseUrlSession() + with pytest.raises(ValueError): + session.get('/') + + def test_request_override_base(self): + session = sessions.BaseUrlSession('https://www.google.com') + recorder = get_betamax(session) + with recorder.use_cassette('simple_get_request'): + response = session.get('https://httpbin.org/get') + response.raise_for_status() + assert response.json()['headers']['Host'] == 'httpbin.org' + + def test_prepared_request_with_base(self): + session = sessions.BaseUrlSession('https://httpbin.org') + request = Request(method="GET", url="/get") + prepared_request = session.prepare_request(request) + recorder = get_betamax(session) + with recorder.use_cassette('simple_get_request'): + response = session.send(prepared_request) + response.raise_for_status() + + def test_prepared_request_without_base(self): + session = sessions.BaseUrlSession() + request = Request(method="GET", url="/") + with pytest.raises(ValueError): + prepared_request = session.prepare_request(request) + session.send(prepared_request) + + def test_prepared_request_override_base(self): + session = sessions.BaseUrlSession('https://www.google.com') + request = Request(method="GET", url="https://httpbin.org/get") + prepared_request = session.prepare_request(request) + recorder = get_betamax(session) + with recorder.use_cassette('simple_get_request'): + response = session.send(prepared_request) + response.raise_for_status() + assert response.json()['headers']['Host'] == 'httpbin.org' diff --git a/tests/test_socket_options_adapter.py b/tests/test_socket_options_adapter.py new file mode 100644 index 0000000..414772e --- /dev/null +++ b/tests/test_socket_options_adapter.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +"""Tests for the SocketOptionsAdapter and TCPKeepAliveAdapter.""" +import contextlib +import platform +import socket +import sys + +import pytest +try: + from unittest import mock +except ImportError: + import mock +import requests +from requests_toolbelt._compat import poolmanager + +from requests_toolbelt.adapters import socket_options + + +@contextlib.contextmanager +def remove_keepidle(): + """A context manager to remove TCP_KEEPIDLE from socket.""" + TCP_KEEPIDLE = getattr(socket, 'TCP_KEEPIDLE', None) + if TCP_KEEPIDLE is not None: + del socket.TCP_KEEPIDLE + + yield + + if TCP_KEEPIDLE is not None: + socket.TCP_KEEPIDLE = TCP_KEEPIDLE + + +@contextlib.contextmanager +def set_keepidle(value): + """A context manager to set TCP_KEEPALIVE on socket always.""" + TCP_KEEPIDLE = getattr(socket, 'TCP_KEEPIDLE', None) + socket.TCP_KEEPIDLE = value + + yield + + if TCP_KEEPIDLE is not None: + socket.TCP_KEEPIDLE = TCP_KEEPIDLE + else: + del socket.TCP_KEEPIDLE + + +@mock.patch.object(requests, '__build__', 0x020500) +@mock.patch.object(poolmanager, 'PoolManager') +def test_options_passing_on_newer_requests(PoolManager): + """Show that options are passed for a new enough version of requests.""" + fake_opts = [('test', 'options', 'fake')] + adapter = socket_options.SocketOptionsAdapter( + socket_options=fake_opts, + pool_connections=10, + pool_maxsize=5, + pool_block=True, + ) + PoolManager.assert_called_once_with( + num_pools=10, maxsize=5, block=True, + socket_options=fake_opts + ) + assert adapter.socket_options == fake_opts + + +@mock.patch.object(requests, '__build__', 0x020300) +@mock.patch.object(poolmanager, 'PoolManager') +def test_options_not_passed_on_older_requests(PoolManager): + """Show that options are not passed for older versions of requests.""" + fake_opts = [('test', 'options', 'fake')] + socket_options.SocketOptionsAdapter( + socket_options=fake_opts, + pool_connections=10, + pool_maxsize=5, + pool_block=True, + ) + assert PoolManager.called is False + + +@pytest.mark.xfail(sys.version_info.major == 2 and platform.system() == "Windows", + reason="Windows does not have TCP_KEEPINTVL in Python 2") +@mock.patch.object(requests, '__build__', 0x020500) +@mock.patch.object(poolmanager, 'PoolManager') +def test_keep_alive_on_newer_requests_no_idle(PoolManager): + """Show that options are generated correctly from kwargs.""" + socket_opts = [ + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + (socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10), + (socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 10), + ] + with remove_keepidle(): + adapter = socket_options.TCPKeepAliveAdapter( + idle=30, interval=10, count=10, + pool_connections=10, + pool_maxsize=5, + pool_block=True, + ) + PoolManager.assert_called_once_with( + num_pools=10, maxsize=5, block=True, + socket_options=socket_opts + ) + assert adapter.socket_options == socket_opts + + +@pytest.mark.xfail(sys.version_info.major == 2 and platform.system() == "Windows", + reason="Windows does not have TCP_KEEPINTVL in Python 2") +@mock.patch.object(requests, '__build__', 0x020500) +@mock.patch.object(poolmanager, 'PoolManager') +def test_keep_alive_on_newer_requests_with_idle(PoolManager): + """Show that options are generated correctly from kwargs with KEEPIDLE.""" + with set_keepidle(3000): + socket_opts = [ + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1), + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + (socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10), + (socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 10), + (socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30), + ] + adapter = socket_options.TCPKeepAliveAdapter( + idle=30, interval=10, count=10, + pool_connections=10, + pool_maxsize=5, + pool_block=True, + ) + + PoolManager.assert_called_once_with( + num_pools=10, maxsize=5, block=True, + socket_options=socket_opts + ) + assert adapter.socket_options == socket_opts diff --git a/tests/test_source_adapter.py b/tests/test_source_adapter.py new file mode 100644 index 0000000..cbfcbd8 --- /dev/null +++ b/tests/test_source_adapter.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +from requests.adapters import DEFAULT_POOLSIZE, DEFAULT_POOLBLOCK +try: + from unittest.mock import patch +except ImportError: + from mock import patch +from requests_toolbelt.adapters.source import SourceAddressAdapter + +import pytest + + +@patch('requests_toolbelt.adapters.source.poolmanager') +def test_source_address_adapter_string(poolmanager): + SourceAddressAdapter('10.10.10.10') + + poolmanager.PoolManager.assert_called_once_with( + num_pools=DEFAULT_POOLSIZE, + maxsize=DEFAULT_POOLSIZE, + block=DEFAULT_POOLBLOCK, + source_address=('10.10.10.10', 0) + ) + + +@patch('requests_toolbelt.adapters.source.poolmanager') +def test_source_address_adapter_tuple(poolmanager): + SourceAddressAdapter(('10.10.10.10', 80)) + + poolmanager.PoolManager.assert_called_once_with( + num_pools=DEFAULT_POOLSIZE, + maxsize=DEFAULT_POOLSIZE, + block=DEFAULT_POOLBLOCK, + source_address=('10.10.10.10', 80) + ) + + +@patch('requests_toolbelt.adapters.source.poolmanager') +def test_source_address_adapter_type_error(poolmanager): + with pytest.raises(TypeError): + SourceAddressAdapter({'10.10.10.10': 80}) + + assert not poolmanager.PoolManager.called diff --git a/tests/test_ssladapter.py b/tests/test_ssladapter.py new file mode 100644 index 0000000..51d16ed --- /dev/null +++ b/tests/test_ssladapter.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +try: + from unittest import mock +except ImportError: + import mock +import pytest +import requests +import unittest + +from requests_toolbelt import SSLAdapter +from . import get_betamax + + +class TestSSLAdapter(unittest.TestCase): + def setUp(self): + self.session = requests.Session() + self.session.mount('https://', SSLAdapter('SSLv3')) + self.recorder = get_betamax(self.session) + + def test_klevas(self): + with self.recorder.use_cassette('klevas_vu_lt_ssl3'): + r = self.session.get('https://klevas.vu.lt/') + assert r.status_code == 200 + + @pytest.mark.skipif(requests.__build__ < 0x020400, + reason="Requires Requests v2.4.0 or later") + @mock.patch('requests.packages.urllib3.poolmanager.ProxyManager') + def test_proxies(self, ProxyManager): + a = SSLAdapter('SSLv3') + a.proxy_manager_for('http://127.0.0.1:8888') + + assert ProxyManager.call_count == 1 + kwargs = ProxyManager.call_args_list[0][1] + assert kwargs['ssl_version'] == 'SSLv3' diff --git a/tests/test_streaming_iterator.py b/tests/test_streaming_iterator.py new file mode 100644 index 0000000..cdb9ca5 --- /dev/null +++ b/tests/test_streaming_iterator.py @@ -0,0 +1,68 @@ +import io + +from requests_toolbelt.streaming_iterator import StreamingIterator + +import pytest + +@pytest.fixture(params=[True, False]) +def get_iterable(request): + ''' + When this fixture is used, the test is run twice -- once with the iterable + being a file-like object, once being an iterator. + ''' + is_file = request.param + def inner(chunks): + if is_file: + return io.BytesIO(b''.join(chunks)) + return iter(chunks) + return inner + + +class TestStreamingIterator(object): + @pytest.fixture(autouse=True) + def setup(self, get_iterable): + self.chunks = [b'here', b'are', b'some', b'chunks'] + self.size = 17 + self.uploader = StreamingIterator(self.size, get_iterable(self.chunks)) + + def test_read_returns_all_chunks_in_one(self): + assert self.uploader.read() == b''.join(self.chunks) + + def test_read_returns_empty_string_after_exhausting_the_iterator(self): + for i in range(0, 4): + self.uploader.read(8192) + + assert self.uploader.read() == b'' + assert self.uploader.read(8192) == b'' + + +class TestStreamingIteratorWithLargeChunks(object): + @pytest.fixture(autouse=True) + def setup(self, get_iterable): + self.letters = [b'a', b'b', b'c', b'd', b'e'] + self.chunks = (letter * 2000 for letter in self.letters) + self.size = 5 * 2000 + self.uploader = StreamingIterator(self.size, get_iterable(self.chunks)) + + def test_returns_the_amount_requested(self): + chunk_size = 1000 + bytes_read = 0 + while True: + b = self.uploader.read(chunk_size) + if not b: + break + assert len(b) == chunk_size + bytes_read += len(b) + + assert bytes_read == self.size + + def test_returns_all_of_the_bytes(self): + chunk_size = 8192 + bytes_read = 0 + while True: + b = self.uploader.read(chunk_size) + if not b: + break + bytes_read += len(b) + + assert bytes_read == self.size diff --git a/tests/test_user_agent.py b/tests/test_user_agent.py new file mode 100644 index 0000000..53a4e5f --- /dev/null +++ b/tests/test_user_agent.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +import unittest +import sys + +try: + from unittest.mock import patch +except ImportError: + from mock import patch +import pytest + +from requests_toolbelt.utils import user_agent as ua + + +class Object(object): + """ + A simple mock object that can have attributes added to it. + """ + pass + + +class TestUserAgentBuilder(unittest.TestCase): + def test_only_user_agent_name(self): + assert 'fake/1.0.0' == ua.UserAgentBuilder('fake', '1.0.0').build() + + def test_includes_extras(self): + expected = 'fake/1.0.0 another-fake/2.0.1 yet-another-fake/17.1.0' + actual = ua.UserAgentBuilder('fake', '1.0.0').include_extras([ + ('another-fake', '2.0.1'), + ('yet-another-fake', '17.1.0'), + ]).build() + assert expected == actual + + @patch('platform.python_implementation', return_value='CPython') + @patch('platform.python_version', return_value='2.7.13') + def test_include_implementation(self, *_): + expected = 'fake/1.0.0 CPython/2.7.13' + actual = ua.UserAgentBuilder('fake', '1.0.0').include_implementation( + ).build() + assert expected == actual + + @patch('platform.system', return_value='Linux') + @patch('platform.release', return_value='4.9.5') + def test_include_system(self, *_): + expected = 'fake/1.0.0 Linux/4.9.5' + actual = ua.UserAgentBuilder('fake', '1.0.0').include_system( + ).build() + assert expected == actual + + +class TestUserAgent(unittest.TestCase): + def test_user_agent_provides_package_name(self): + assert "my-package" in ua.user_agent("my-package", "0.0.1") + + def test_user_agent_provides_package_version(self): + assert "0.0.1" in ua.user_agent("my-package", "0.0.1") + + def test_user_agent_builds_extras_appropriately(self): + assert "extra/1.0.0" in ua.user_agent( + "my-package", "0.0.1", extras=[("extra", "1.0.0")] + ) + + def test_user_agent_checks_extras_for_tuples_of_incorrect_length(self): + with pytest.raises(ValueError): + ua.user_agent("my-package", "0.0.1", extras=[ + ("extra", "1.0.0", "oops") + ]) + + with pytest.raises(ValueError): + ua.user_agent("my-package", "0.0.1", extras=[ + ("extra",) + ]) + + +class TestImplementationString(unittest.TestCase): + @patch('platform.python_implementation') + @patch('platform.python_version') + def test_cpython_implementation(self, mock_version, mock_implementation): + mock_implementation.return_value = 'CPython' + mock_version.return_value = '2.7.5' + assert 'CPython/2.7.5' == ua._implementation_string() + + @patch('platform.python_implementation') + def test_pypy_implementation_final(self, mock_implementation): + mock_implementation.return_value = 'PyPy' + sys.pypy_version_info = Object() + sys.pypy_version_info.major = 2 + sys.pypy_version_info.minor = 0 + sys.pypy_version_info.micro = 1 + sys.pypy_version_info.releaselevel = 'final' + + assert 'PyPy/2.0.1' == ua._implementation_string() + + @patch('platform.python_implementation') + def test_pypy_implementation_non_final(self, mock_implementation): + mock_implementation.return_value = 'PyPy' + sys.pypy_version_info = Object() + sys.pypy_version_info.major = 2 + sys.pypy_version_info.minor = 0 + sys.pypy_version_info.micro = 1 + sys.pypy_version_info.releaselevel = 'beta2' + + assert 'PyPy/2.0.1beta2' == ua._implementation_string() + + @patch('platform.python_implementation') + def test_unknown_implementation(self, mock_implementation): + mock_implementation.return_value = "Lukasa'sSuperPython" + + assert "Lukasa'sSuperPython/Unknown" == ua._implementation_string() diff --git a/tests/test_x509_adapter.py b/tests/test_x509_adapter.py new file mode 100644 index 0000000..84a10e2 --- /dev/null +++ b/tests/test_x509_adapter.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +import requests +import unittest +import pytest + +try: + import OpenSSL +except ImportError: + PYOPENSSL_AVAILABLE = False +else: + PYOPENSSL_AVAILABLE = True + from requests_toolbelt.adapters.x509 import X509Adapter + from cryptography import x509 + from cryptography.hazmat.primitives.serialization import ( + Encoding, + PrivateFormat, + BestAvailableEncryption, + load_pem_private_key, + ) + import trustme + +from requests_toolbelt import exceptions as exc +from . import get_betamax + +REQUESTS_SUPPORTS_SSL_CONTEXT = requests.__build__ >= 0x021200 + +pytestmark = pytest.mark.filterwarnings( + "ignore:'urllib3.contrib.pyopenssl' module is deprecated:DeprecationWarning") + + +class TestX509Adapter(unittest.TestCase): + """Tests a simple requests.get() call using a .p12 cert. + """ + def setUp(self): + self.pkcs12_password_bytes = "test".encode('utf8') + self.session = requests.Session() + + @pytest.mark.skipif(not REQUESTS_SUPPORTS_SSL_CONTEXT, + reason="Requires Requests v2.12.0 or later") + @pytest.mark.skipif(not PYOPENSSL_AVAILABLE, + reason="Requires OpenSSL") + def test_x509_pem(self): + ca = trustme.CA() + cert = ca.issue_cert(u'pkiprojecttest01.dev.labs.internal') + cert_bytes = cert.cert_chain_pems[0].bytes() + pk_bytes = cert.private_key_pem.bytes() + + adapter = X509Adapter(max_retries=3, cert_bytes=cert_bytes, pk_bytes=pk_bytes) + self.session.mount('https://', adapter) + recorder = get_betamax(self.session) + with recorder.use_cassette('test_x509_adapter_pem'): + r = self.session.get('https://pkiprojecttest01.dev.labs.internal/', verify=False) + + assert r.status_code == 200 + assert r.text + + @pytest.mark.skipif(not REQUESTS_SUPPORTS_SSL_CONTEXT, + reason="Requires Requests v2.12.0 or later") + @pytest.mark.skipif(not PYOPENSSL_AVAILABLE, + reason="Requires OpenSSL") + def test_x509_der_and_password(self): + ca = trustme.CA() + cert = ca.issue_cert(u'pkiprojecttest01.dev.labs.internal') + cert_bytes = x509.load_pem_x509_certificate( + cert.cert_chain_pems[0].bytes()).public_bytes(Encoding.DER) + pem_pk = load_pem_private_key(cert.private_key_pem.bytes(), password=None) + pk_bytes = pem_pk.private_bytes(Encoding.DER, PrivateFormat.PKCS8, + BestAvailableEncryption(self.pkcs12_password_bytes)) + + adapter = X509Adapter(max_retries=3, cert_bytes=cert_bytes, pk_bytes=pk_bytes, + password=self.pkcs12_password_bytes, encoding=Encoding.DER) + self.session.mount('https://', adapter) + recorder = get_betamax(self.session) + with recorder.use_cassette('test_x509_adapter_der'): + r = self.session.get('https://pkiprojecttest01.dev.labs.internal/', verify=False) + + assert r.status_code == 200 + assert r.text + + @pytest.mark.skipif(REQUESTS_SUPPORTS_SSL_CONTEXT, reason="Will not raise exc") + def test_requires_new_enough_requests(self): + with pytest.raises(exc.VersionMismatchError): + X509Adapter() diff --git a/tests/threaded/__init__.py b/tests/threaded/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/threaded/test_api.py b/tests/threaded/test_api.py new file mode 100644 index 0000000..0a882d8 --- /dev/null +++ b/tests/threaded/test_api.py @@ -0,0 +1,62 @@ +"""Module containing tests for requests_toolbelt.threaded API.""" + +try: + from unittest import mock +except ImportError: + import mock +import pytest + +from requests_toolbelt._compat import queue +from requests_toolbelt import threaded + + +def test_creates_a_pool_for_the_user(): + """Assert a Pool object is used correctly and as we expect. + + This just ensures that we're not jumping through any extra hoops with our + internal usage of a Pool object. + """ + mocked_pool = mock.Mock(spec=['join_all', 'responses', 'exceptions']) + with mock.patch('requests_toolbelt.threaded.pool.Pool') as Pool: + Pool.return_value = mocked_pool + threaded.map([{}, {}]) + + assert Pool.called is True + _, kwargs = Pool.call_args + assert 'job_queue' in kwargs + assert isinstance(kwargs['job_queue'], queue.Queue) + mocked_pool.join_all.assert_called_once_with() + mocked_pool.responses.assert_called_once_with() + mocked_pool.exceptions.assert_called_once_with() + + +def test_raises_a_value_error_for_non_dictionaries(): + """Exercise our lazy valdation.""" + with pytest.raises(ValueError): + threaded.map([[], []]) + + +def test_raises_a_value_error_for_falsey_requests(): + """Assert that the requests param is truthy.""" + with pytest.raises(ValueError): + threaded.map([]) + + with pytest.raises(ValueError): + threaded.map(None) + + +def test_passes_on_kwargs(): + """Verify that we pass on kwargs to the Pool constructor.""" + mocked_pool = mock.Mock(spec=['join_all', 'responses', 'exceptions']) + with mock.patch('requests_toolbelt.threaded.pool.Pool') as Pool: + Pool.return_value = mocked_pool + threaded.map([{}, {}], num_processes=1000, + initializer=test_passes_on_kwargs) + + _, kwargs = Pool.call_args + assert 'job_queue' in kwargs + assert 'num_processes' in kwargs + assert 'initializer' in kwargs + + assert kwargs['num_processes'] == 1000 + assert kwargs['initializer'] == test_passes_on_kwargs diff --git a/tests/threaded/test_pool.py b/tests/threaded/test_pool.py new file mode 100644 index 0000000..fec2be7 --- /dev/null +++ b/tests/threaded/test_pool.py @@ -0,0 +1,234 @@ +"""Module containing the tests for requests_toolbelt.threaded.pool.""" +try: + import queue # Python 3 +except ImportError: + import Queue as queue +import unittest + +try: + from unittest import mock +except ImportError: + import mock +import pytest + +from requests_toolbelt.threaded import pool +from requests_toolbelt.threaded import thread + + +class TestPool(unittest.TestCase): + + """Collection of tests for requests_toolbelt.threaded.pool.Pool.""" + + def test_requires_positive_number_of_processes(self): + """Show that the number of processes has to be > 0.""" + with pytest.raises(ValueError): + pool.Pool(None, num_processes=0) + + with pytest.raises(ValueError): + pool.Pool(None, num_processes=-1) + + def test_number_of_processes_can_be_arbitrary(self): + """Show that the number of processes can be set.""" + job_queue = queue.Queue() + p = pool.Pool(job_queue, num_processes=100) + assert p._processes == 100 + assert len(p._pool) == 100 + + job_queue = queue.Queue() + p = pool.Pool(job_queue, num_processes=1) + assert p._processes == 1 + assert len(p._pool) == 1 + + def test_initializer_is_called(self): + """Ensure that the initializer function is called.""" + job_queue = queue.Queue() + initializer = mock.MagicMock() + pool.Pool(job_queue, num_processes=1, initializer=initializer) + assert initializer.called is True + initializer.assert_called_once_with(mock.ANY) + + def test_auth_generator_is_called(self): + """Ensure that the auth_generator function is called.""" + job_queue = queue.Queue() + auth_generator = mock.MagicMock() + pool.Pool(job_queue, num_processes=1, auth_generator=auth_generator) + assert auth_generator.called is True + auth_generator.assert_called_once_with(mock.ANY) + + def test_session_is_called(self): + """Ensure that the session function is called.""" + job_queue = queue.Queue() + session = mock.MagicMock() + pool.Pool(job_queue, num_processes=1, session=session) + assert session.called is True + session.assert_called_once_with() + + def test_from_exceptions_populates_a_queue(self): + """Ensure a Queue is properly populated from exceptions.""" + urls = ["https://httpbin.org/get?n={}".format(n) for n in range(5)] + Exc = pool.ThreadException + excs = (Exc({'method': 'GET', 'url': url}, None) for url in urls) + + job_queue = mock.MagicMock() + with mock.patch.object(queue, 'Queue', return_value=job_queue): + with mock.patch.object(thread, 'SessionThread'): + pool.Pool.from_exceptions(excs) + + assert job_queue.put.call_count == 5 + assert job_queue.put.mock_calls == [ + mock.call({'method': 'GET', 'url': url}) + for url in urls + ] + + def test_from_urls_constructs_get_requests(self): + """Ensure a Queue is properly populated from an iterable of urls.""" + urls = ["https://httpbin.org/get?n={}".format(n) for n in range(5)] + + job_queue = mock.MagicMock() + with mock.patch.object(queue, 'Queue', return_value=job_queue): + with mock.patch.object(thread, 'SessionThread'): + pool.Pool.from_urls(urls) + + assert job_queue.put.call_count == 5 + assert job_queue.put.mock_calls == [ + mock.call({'method': 'GET', 'url': url}) + for url in urls + ] + + def test_from_urls_constructs_get_requests_with_kwargs(self): + """Ensure a Queue is properly populated from an iterable of urls.""" + def merge(*args): + final = {} + for d in args: + final.update(d) + return final + + urls = ["https://httpbin.org/get?n={}".format(n) for n in range(5)] + + kwargs = {'stream': True, 'headers': {'Accept': 'application/json'}} + job_queue = mock.MagicMock() + with mock.patch.object(queue, 'Queue', return_value=job_queue): + with mock.patch.object(thread, 'SessionThread'): + pool.Pool.from_urls(urls, kwargs) + + assert job_queue.put.call_count == 5 + assert job_queue.put.mock_calls == [ + mock.call(merge({'method': 'GET', 'url': url}, kwargs)) + for url in urls + ] + + def test_join_all(self): + """Ensure that all threads are joined properly.""" + session_threads = [] + + def _side_effect(*args, **kwargs): + thread = mock.MagicMock() + session_threads.append(thread) + return thread + + with mock.patch.object(thread, 'SessionThread', + side_effect=_side_effect): + pool.Pool(None).join_all() + + for st in session_threads: + st.join.assert_called_once_with() + + def test_get_response_returns_thread_response(self): + """Ensure that a ThreadResponse is made when there's data.""" + queues = [] + + def _side_effect(): + q = mock.MagicMock() + q.get_nowait.return_value = ({}, None) + queues.append(q) + return q + + with mock.patch.object(queue, 'Queue', side_effect=_side_effect): + with mock.patch.object(thread, 'SessionThread'): + p = pool.Pool(None) + + assert len(queues) == 2 + + assert isinstance(p.get_response(), pool.ThreadResponse) + assert len([q for q in queues if q.get_nowait.called]) == 1 + + def test_get_exception_returns_thread_exception(self): + """Ensure that a ThreadException is made when there's data.""" + queues = [] + + def _side_effect(): + q = mock.MagicMock() + q.get_nowait.return_value = ({}, None) + queues.append(q) + return q + + with mock.patch.object(queue, 'Queue', side_effect=_side_effect): + with mock.patch.object(thread, 'SessionThread'): + p = pool.Pool(None) + + assert len(queues) == 2 + + assert isinstance(p.get_exception(), pool.ThreadException) + assert len([q for q in queues if q.get_nowait.called]) == 1 + + def test_get_response_returns_none_when_queue_is_empty(self): + """Ensure that None is returned when the response Queue is empty.""" + queues = [] + + def _side_effect(): + q = mock.MagicMock() + q.get_nowait.side_effect = queue.Empty() + queues.append(q) + return q + + with mock.patch.object(queue, 'Queue', side_effect=_side_effect): + with mock.patch.object(thread, 'SessionThread'): + p = pool.Pool(None) + + assert len(queues) == 2 + + assert p.get_response() is None + assert len([q for q in queues if q.get_nowait.called]) == 1 + + def test_get_exception_returns_none_when_queue_is_empty(self): + """Ensure that None is returned when the exception Queue is empty.""" + queues = [] + + def _side_effect(): + q = mock.MagicMock() + q.get_nowait.side_effect = queue.Empty() + queues.append(q) + return q + + with mock.patch.object(queue, 'Queue', side_effect=_side_effect): + with mock.patch.object(thread, 'SessionThread'): + p = pool.Pool(None) + + assert len(queues) == 2 + + assert p.get_exception() is None + assert len([q for q in queues if q.get_nowait.called]) == 1 + + def test_lists_are_correctly_returned(self): + """Ensure that exceptions and responses return correct lists.""" + def _make_queue(): + q = queue.Queue() + q.put(({}, None)) + return q + + with mock.patch.object(thread, 'SessionThread'): + p = pool.Pool(None) + + # Set up real queues. + p._response_queue = _make_queue() + p._exc_queue = _make_queue() + + excs = list(p.exceptions()) + assert len(excs) == 1 + for exc in excs: + assert isinstance(exc, pool.ThreadException) + + resps = list(p.responses()) + assert len(resps) == 1 + for resp in resps: + assert isinstance(resp, pool.ThreadResponse) diff --git a/tests/threaded/test_thread.py b/tests/threaded/test_thread.py new file mode 100644 index 0000000..07b4b7c --- /dev/null +++ b/tests/threaded/test_thread.py @@ -0,0 +1,137 @@ +"""Module containing the tests for requests_toolbelt.threaded.thread.""" +try: + import queue # Python 3 +except ImportError: + import Queue as queue +import threading +import unittest +import uuid + +try: + from unittest import mock +except ImportError: + import mock +import requests.exceptions + +from requests_toolbelt.threaded import thread + + +def _make_mocks(): + return (mock.MagicMock() for _ in range(4)) + + +def _initialize_a_session_thread(session=None, job_queue=None, + response_queue=None, exception_queue=None): + if job_queue is None: + job_queue = queue.Queue() + with mock.patch.object(threading, 'Thread') as Thread: + thread_instance = mock.MagicMock() + Thread.return_value = thread_instance + st = thread.SessionThread( + initialized_session=session, + job_queue=job_queue, + response_queue=response_queue, + exception_queue=exception_queue, + ) + + return (st, thread_instance, Thread) + + +class TestSessionThread(unittest.TestCase): + + """Tests for requests_toolbelt.threaded.thread.SessionThread.""" + + def test_thread_initialization(self): + """Test the way a SessionThread is initialized. + + We want to ensure that we creat a thread with a name generated by the + uuid module, and that we pass the right method to use as a target. + """ + with mock.patch.object(uuid, 'uuid4', return_value='test'): + (st, thread_instance, Thread) = _initialize_a_session_thread() + + Thread.assert_called_once_with(target=st._make_request, name='test') + assert thread_instance.daemon is True + assert thread_instance._state is 0 + thread_instance.start.assert_called_once_with() + + def test_is_alive_proxies_to_worker(self): + """Test that we proxy the is_alive method to the Thread.""" + job_queue = queue.Queue() + with mock.patch.object(threading, 'Thread') as Thread: + thread_instance = mock.MagicMock() + Thread.return_value = thread_instance + st = thread.SessionThread(None, job_queue, None, None) + + st.is_alive() + thread_instance.is_alive.assert_called_once_with() + + def test_join_proxies_to_worker(self): + """Test that we proxy the join method to the Thread.""" + st, thread_instance, _ = _initialize_a_session_thread() + + st.join() + thread_instance.join.assert_called_once_with() + + def test_handle_valid_request(self): + """Test that a response is added to the right queue.""" + session, job_queue, response_queue, exception_queue = _make_mocks() + response = mock.MagicMock() + session.request.return_value = response + + st, _, _ = _initialize_a_session_thread( + session, job_queue, response_queue, exception_queue) + + st._handle_request({'method': 'GET', 'url': 'http://example.com'}) + session.request.assert_called_once_with( + method='GET', + url='http://example.com' + ) + + response_queue.put.assert_called_once_with( + ({'method': 'GET', 'url': 'http://example.com'}, response) + ) + assert exception_queue.put.called is False + assert job_queue.get.called is False + assert job_queue.get_nowait.called is False + assert job_queue.get_nowait.called is False + assert job_queue.task_done.called is True + + def test_handle_invalid_request(self): + """Test that exceptions from requests are added to the right queue.""" + session, job_queue, response_queue, exception_queue = _make_mocks() + exception = requests.exceptions.InvalidURL() + + def _side_effect(*args, **kwargs): + raise exception + + # Make the request raise an exception + session.request.side_effect = _side_effect + + st, _, _ = _initialize_a_session_thread( + session, job_queue, response_queue, exception_queue) + + st._handle_request({'method': 'GET', 'url': 'http://example.com'}) + session.request.assert_called_once_with( + method='GET', + url='http://example.com' + ) + + exception_queue.put.assert_called_once_with( + ({'method': 'GET', 'url': 'http://example.com'}, exception) + ) + assert response_queue.put.called is False + assert job_queue.get.called is False + assert job_queue.get_nowait.called is False + assert job_queue.get_nowait.called is False + assert job_queue.task_done.called is True + + def test_make_request(self): + """Test that _make_request exits when the queue is Empty.""" + job_queue = next(_make_mocks()) + job_queue.get_nowait.side_effect = queue.Empty() + + st, _, _ = _initialize_a_session_thread(job_queue=job_queue) + st._make_request() + + job_queue.get_nowait.assert_called_once_with() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..253c8ba --- /dev/null +++ b/tox.ini @@ -0,0 +1,80 @@ +[tox] +envlist = py27,py37,py38,py39,py310,pypy,pypy3,{py27,py37}-flake8,noopenssl,docstrings + +[gh-actions] +python = + 2.7: py27 + 3.7: py37, py37-flake8, noopenssl + 3.8: py38 + 3.9: py39 + 3.10: py310 + +[testenv] +pip_pre = False +deps = + requests{env:REQUESTS_VERSION:>=2.0.1,<3.0.0} + pytest + mock;python_version<"3.3" + pyopenssl + ndg-httpsclient + betamax>0.5.0 + trustme +commands = + pytest -W error::DeprecationWarning {posargs} + +[testenv:noopenssl] +basepython = python3.7 +pip_pre = False +deps = + requests{env:REQUESTS_VERSION:>=2.0.1,<3.0.0} + pytest + mock;python_version<"3.3" + betamax>0.5.0 +commands = + pytest -W error::DeprecationWarning {posargs} + +[testenv:py27-flake8] +basepython = python2.7 +deps = + flake8 +commands = flake8 {posargs} requests_toolbelt + +[testenv:py37-flake8] +basepython = python3.7 +deps = + flake8 +commands = flake8 {posargs} requests_toolbelt + +[testenv:docstrings] +deps = + flake8 + flake8-docstrings +commands = flake8 {posargs} requests_toolbelt + +[testenv:docs] +deps = + sphinx>=1.3.0 + sphinx_rtd_theme + pyopenssl + . +commands = + sphinx-build -E -c docs -b html docs/ docs/_build/html + +[testenv:readme] +deps = + readme_renderer +commands = + python setup.py check -m -r -s + +[testenv:release] +deps = + twine >= 1.4.0 + wheel +commands = + python setup.py sdist bdist_wheel + twine upload --skip-existing dist/* + +[pytest] +addopts = -q +norecursedirs = *.egg .git .* _* +xfail_strict = true