commit 4ea5d668127addfef678f509127196ac4ff60a99 Author: su-fang Date: Mon Feb 6 15:37:15 2023 +0800 Import Upstream version 1.1.4 diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..16ca34c --- /dev/null +++ b/COPYING @@ -0,0 +1,22 @@ +Copyright (C) <2011-2021> Gabriel Falcão + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..29295ae --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include COPYING +include Makefile +include README.rst +include requirements.txt +include tests/functional/fixtures/playback-*.json +include tox.ini +recursive-include docs *.* +recursive-include tests *.py +include *.cfg *.rst *.txt \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d3d337d --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +.PHONY: tests all unit functional clean dependencies tdd docs html purge dist setup + +GIT_ROOT := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) +DOCS_ROOT := $(GIT_ROOT)/docs +HTML_ROOT := $(DOCS_ROOT)/build/html +VENV_ROOT := $(GIT_ROOT)/.venv +VENV ?= $(VENV_ROOT) +DOCS_INDEX := $(HTML_ROOT)/index.html + +export VENV +export PYTHONASYNCIODEBUG :=1 + + +all: dependencies tests + +$(VENV): # creates $(VENV) folder if does not exist + python3 -mvenv $(VENV) + $(VENV)/bin/pip install -U pip setuptools + +$(VENV)/bin/sphinx-build $(VENV)/bin/twine $(VENV)/bin/nosetests $(VENV)/bin/pytest $(VENV)/bin/python $(VENV)/bin/pip: # installs latest pip + test -e $(VENV)/bin/pip || make $(VENV) + $(MAKE) setup + +setup: | $(VENV)/bin/pip + $(VENV)/bin/pip install -r development.txt + $(VENV)/bin/pip install -e . + +# Runs the unit and functional tests +tests: unit bugfixes functional pyopenssl + + +tdd: $(VENV)/bin/nosetests # runs all tests + $(VENV)/bin/nosetests tests --with-watch --cover-erase + +# Install dependencies +dependencies: | setup $(VENV)/bin/nosetests + +# runs unit tests +unit: $(VENV)/bin/nosetests # runs only unit tests + $(VENV)/bin/nosetests --cover-erase tests/unit + + +pyopenssl: $(VENV)/bin/nosetests + $(VENV)/bin/nosetests --cover-erase tests/pyopenssl + +bugfixes: $(VENV)/bin/nosetests $(VENV)/bin/pytest # runs tests for specific bugfixes + $(VENV)/bin/nosetests tests/bugfixes/nosetests + $(VENV)/bin/pytest --maxfail=1 --mypy tests/bugfixes/pytest + +# runs functional tests +functional: $(VENV)/bin/nosetests # runs functional tests + $(VENV)/bin/nosetests tests/functional + + + +$(DOCS_INDEX): $(VENV)/bin/sphinx-build + cd docs && make html + +html: $(DOCS_INDEX) $(VENV)/bin/sphinx-build + +docs: $(DOCS_INDEX) $(VENV)/bin/sphinx-build + open $(DOCS_INDEX) + +release: | clean tests html + @rm -rf dist/* + @./.release + @make pypi + +dist: | clean + $(VENV)/bin/python setup.py build sdist + +pypi: dist | $(VENV)/bin/twine + $(VENV)/bin/twine upload dist/*.tar.gz + +# cleanup temp files +clean: + rm -rf $(HTML_ROOT) build dist + + +# purge all virtualenv and temp files, causes everything to be rebuilt +# from scratch by other tasks +purge: clean + rm -rf $(VENV) diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..df3fece --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,190 @@ +Metadata-Version: 1.2 +Name: httpretty +Version: 1.1.4 +Summary: HTTP client mock for Python +Home-page: https://httpretty.readthedocs.io/en/latest/ +Author: Gabriel Falcao +Author-email: gabriel@nacaolivre.org +License: MIT +Project-URL: Documentation, https://httpretty.readthedocs.io/en/latest/ +Project-URL: Source Code, https://github.com/gabrielfalcao/httpretty +Project-URL: Issue Tracker, https://github.com/gabrielfalcao/httpretty/issues +Project-URL: Continuous Integration, https://github.com/gabrielfalcao/HTTPretty/actions/workflows/pyenv.yml?query=branch%3Amaster+event%3Apush +Project-URL: Test Coverage, https://codecov.io/gh/gabrielfalcao/httpretty +Description: HTTPretty 1.1.4 + =============== + + .. image:: https://github.com/gabrielfalcao/HTTPretty/raw/master/docs/source/_static/logo.svg?sanitize=true + + HTTP Client mocking tool for Python created by `Gabriel Falcão `_ . It provides a full fake TCP socket module. Inspired by `FakeWeb `_ + + + - `Github Repository `_ + - `Documentation `_ + - `PyPI Package `_ + + + **Python Support:** + + - **3.6** + - **3.7** + - **3.8** + - **3.9** + + .. image:: https://img.shields.io/pypi/dm/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/codecov/c/github/gabrielfalcao/HTTPretty + :target: https://codecov.io/gh/gabrielfalcao/HTTPretty + + .. image:: https://img.shields.io/github/workflow/status/gabrielfalcao/HTTPretty/HTTPretty%20Tests?label=Python%203.6%20-%203.9 + :target: https://github.com/gabrielfalcao/HTTPretty/actions + + .. image:: https://img.shields.io/readthedocs/httpretty + :target: https://httpretty.readthedocs.io/ + + .. image:: https://img.shields.io/github/license/gabrielfalcao/HTTPretty?label=Github%20License + :target: https://github.com/gabrielfalcao/HTTPretty/blob/master/COPYING + + .. image:: https://img.shields.io/pypi/v/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/l/HTTPretty?label=PyPi%20License + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/format/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/status/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/pyversions/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/implementation/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/snyk/vulnerabilities/github/gabrielfalcao/HTTPretty + :target: https://github.com/gabrielfalcao/HTTPretty/network/alerts + + .. image:: https://img.shields.io/github/v/tag/gabrielfalcao/HTTPretty + :target: https://github.com/gabrielfalcao/HTTPretty/releases + + .. |Join the chat at https://gitter.im/gabrielfalcao/HTTPretty| image:: https://badges.gitter.im/gabrielfalcao/HTTPretty.svg + :target: https://gitter.im/gabrielfalcao/HTTPretty?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge + + Install + ------- + + .. code:: bash + + pip install httpretty + + + + Common Use Cases + ================ + + - Test-driven development of API integrations + - Fake responses of external APIs + - Record and playback HTTP requests + + + Simple Example + -------------- + + .. code:: python + + import sure + import httpretty + import requests + + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_httpbin(): + httpretty.register_uri( + httpretty.GET, + "https://httpbin.org/ip", + body='{"origin": "127.0.0.1"}' + ) + + response = requests.get('https://httpbin.org/ip') + response.json().should.equal({'origin': '127.0.0.1'}) + + httpretty.latest_requests().should.have.length_of(1) + httpretty.last_request().should.equal(httpretty.latest_requests()[0]) + httpretty.last_request().body.should.equal('{"origin": "127.0.0.1"}') + + + checking multiple responses + --------------------------- + + .. code:: python + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_post_bodies(): + url = 'http://httpbin.org/post' + httpretty.register_uri(httpretty.POST, url, status=200) + httpretty.register_uri(httpretty.POST, url, status=400) + requests.post(url, data={'foo': 'bar'}) + requests.post(url, data={'zoo': 'zoo'}) + assert 'foo=bar' in httpretty.latest_requests()[0].body + assert 'zoo=bar' in httpretty.latest_requests()[1].body + + + License + ======= + + :: + + + Copyright (C) <2011-2021> Gabriel Falcão + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + Main contributors + ================= + + HTTPretty has received `many contributions `_ + but some folks made remarkable contributions and deserve extra credit: + + - Andrew Gross ~> `@andrewgross `_ + - Hugh Saunders ~> `@hughsaunders `_ + - James Rowe ~> `@JNRowe `_ + - Matt Luongo ~> `@mhluongo `_ + - Steve Pulec ~> `@spulec `_ + - Miro Hrončok ~> `@hroncok `_ + Mario Jonke ~> `@mariojonke `_ + +Platform: UNKNOWN +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python +Classifier: Topic :: Internet :: WWW/HTTP +Classifier: Topic :: Software Development :: Testing +Requires-Python: >=3 diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..c88556c --- /dev/null +++ b/README.rst @@ -0,0 +1,163 @@ +HTTPretty 1.1.4 +=============== + +.. image:: https://github.com/gabrielfalcao/HTTPretty/raw/master/docs/source/_static/logo.svg?sanitize=true + +HTTP Client mocking tool for Python created by `Gabriel Falcão `_ . It provides a full fake TCP socket module. Inspired by `FakeWeb `_ + + +- `Github Repository `_ +- `Documentation `_ +- `PyPI Package `_ + + +**Python Support:** + +- **3.6** +- **3.7** +- **3.8** +- **3.9** + +.. image:: https://img.shields.io/pypi/dm/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/codecov/c/github/gabrielfalcao/HTTPretty + :target: https://codecov.io/gh/gabrielfalcao/HTTPretty + +.. image:: https://img.shields.io/github/workflow/status/gabrielfalcao/HTTPretty/HTTPretty%20Tests?label=Python%203.6%20-%203.9 + :target: https://github.com/gabrielfalcao/HTTPretty/actions + +.. image:: https://img.shields.io/readthedocs/httpretty + :target: https://httpretty.readthedocs.io/ + +.. image:: https://img.shields.io/github/license/gabrielfalcao/HTTPretty?label=Github%20License + :target: https://github.com/gabrielfalcao/HTTPretty/blob/master/COPYING + +.. image:: https://img.shields.io/pypi/v/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/l/HTTPretty?label=PyPi%20License + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/format/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/status/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/pyversions/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/implementation/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/snyk/vulnerabilities/github/gabrielfalcao/HTTPretty + :target: https://github.com/gabrielfalcao/HTTPretty/network/alerts + +.. image:: https://img.shields.io/github/v/tag/gabrielfalcao/HTTPretty + :target: https://github.com/gabrielfalcao/HTTPretty/releases + +.. |Join the chat at https://gitter.im/gabrielfalcao/HTTPretty| image:: https://badges.gitter.im/gabrielfalcao/HTTPretty.svg + :target: https://gitter.im/gabrielfalcao/HTTPretty?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge + +Install +------- + +.. code:: bash + + pip install httpretty + + + +Common Use Cases +================ + +- Test-driven development of API integrations +- Fake responses of external APIs +- Record and playback HTTP requests + + +Simple Example +-------------- + +.. code:: python + + import sure + import httpretty + import requests + + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_httpbin(): + httpretty.register_uri( + httpretty.GET, + "https://httpbin.org/ip", + body='{"origin": "127.0.0.1"}' + ) + + response = requests.get('https://httpbin.org/ip') + response.json().should.equal({'origin': '127.0.0.1'}) + + httpretty.latest_requests().should.have.length_of(1) + httpretty.last_request().should.equal(httpretty.latest_requests()[0]) + httpretty.last_request().body.should.equal('{"origin": "127.0.0.1"}') + + +checking multiple responses +--------------------------- + + .. code:: python + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_post_bodies(): + url = 'http://httpbin.org/post' + httpretty.register_uri(httpretty.POST, url, status=200) + httpretty.register_uri(httpretty.POST, url, status=400) + requests.post(url, data={'foo': 'bar'}) + requests.post(url, data={'zoo': 'zoo'}) + assert 'foo=bar' in httpretty.latest_requests()[0].body + assert 'zoo=bar' in httpretty.latest_requests()[1].body + + +License +======= + +:: + + + Copyright (C) <2011-2021> Gabriel Falcão + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + +Main contributors +================= + +HTTPretty has received `many contributions `_ +but some folks made remarkable contributions and deserve extra credit: + +- Andrew Gross ~> `@andrewgross `_ +- Hugh Saunders ~> `@hughsaunders `_ +- James Rowe ~> `@JNRowe `_ +- Matt Luongo ~> `@mhluongo `_ +- Steve Pulec ~> `@spulec `_ +- Miro Hrončok ~> `@hroncok `_ + Mario Jonke ~> `@mariojonke `_ diff --git a/development.txt b/development.txt new file mode 100644 index 0000000..08eeb36 --- /dev/null +++ b/development.txt @@ -0,0 +1,31 @@ +check-manifest==0.41 +coverage>=5.0.3 +cryptography>=2.8 +eventlet==0.25.1 # issue #254 +flake8>=3.7.9 +freezegun>=0.3.15 +httplib2>=0.17.0 +httpx>=0.18.1 +ipdb>=0.13.2 +mccabe>=0.6.1 +mock>=3.0.5;python_version<"3.3" +ndg-httpsclient>=0.5.1 +nose-randomly>=1.2.6 +nose>=1.3.7 +pathlib2>=2.3.5 +pyOpenSSL>=19.1.0 +redis==3.4.1 +rednose>=1.3.0 +requests-toolbelt>=0.9.1 +singledispatch>=3.4.0.3 +sphinx-rtd-theme>=0.5.2 +sphinx>=4.0.2 +sure>=1.4.11 +tornado>=6.0.4 +tox>=3.14.5 +twine>=1.15.0 +urllib3>=1.25.8 +boto3>=1.17.72 +ndg-httpsclient>=0.5.1 +pytest-mypy==0.8.1 +sphinxcontrib.asciinema==0.3.2 diff --git a/docs/build/doctrees/acks.doctree b/docs/build/doctrees/acks.doctree new file mode 100644 index 0000000..fc578aa Binary files /dev/null and b/docs/build/doctrees/acks.doctree differ diff --git a/docs/build/doctrees/api.doctree b/docs/build/doctrees/api.doctree new file mode 100644 index 0000000..24a7079 Binary files /dev/null and b/docs/build/doctrees/api.doctree differ diff --git a/docs/build/doctrees/changelog.doctree b/docs/build/doctrees/changelog.doctree new file mode 100644 index 0000000..a061233 Binary files /dev/null and b/docs/build/doctrees/changelog.doctree differ diff --git a/docs/build/doctrees/contributing.doctree b/docs/build/doctrees/contributing.doctree new file mode 100644 index 0000000..a4dad81 Binary files /dev/null and b/docs/build/doctrees/contributing.doctree differ diff --git a/docs/build/doctrees/environment.pickle b/docs/build/doctrees/environment.pickle new file mode 100644 index 0000000..7789700 Binary files /dev/null and b/docs/build/doctrees/environment.pickle differ diff --git a/docs/build/doctrees/guides.doctree b/docs/build/doctrees/guides.doctree new file mode 100644 index 0000000..2582eac Binary files /dev/null and b/docs/build/doctrees/guides.doctree differ diff --git a/docs/build/doctrees/index.doctree b/docs/build/doctrees/index.doctree new file mode 100644 index 0000000..1b8952e Binary files /dev/null and b/docs/build/doctrees/index.doctree differ diff --git a/docs/build/doctrees/introduction.doctree b/docs/build/doctrees/introduction.doctree new file mode 100644 index 0000000..03b9eec Binary files /dev/null and b/docs/build/doctrees/introduction.doctree differ diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..52aea0a --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build +set SPHINXPROJ=HTTPretty + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 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 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/source/_static/__pycache__/guide-callback-regex-ipdb.cpython-38.pyc b/docs/source/_static/__pycache__/guide-callback-regex-ipdb.cpython-38.pyc new file mode 100644 index 0000000..bbcc6e5 Binary files /dev/null and b/docs/source/_static/__pycache__/guide-callback-regex-ipdb.cpython-38.pyc differ diff --git a/docs/source/_static/__pycache__/read-timeout.cpython-38-pytest-6.2.4.pyc b/docs/source/_static/__pycache__/read-timeout.cpython-38-pytest-6.2.4.pyc new file mode 100644 index 0000000..351d4cd Binary files /dev/null and b/docs/source/_static/__pycache__/read-timeout.cpython-38-pytest-6.2.4.pyc differ diff --git a/docs/source/_static/__pycache__/read-timeout.cpython-38.pyc b/docs/source/_static/__pycache__/read-timeout.cpython-38.pyc new file mode 100644 index 0000000..dfdfe10 Binary files /dev/null and b/docs/source/_static/__pycache__/read-timeout.cpython-38.pyc differ diff --git a/docs/source/_static/__pycache__/regex-example.cpython-38.pyc b/docs/source/_static/__pycache__/regex-example.cpython-38.pyc new file mode 100644 index 0000000..d3a313d Binary files /dev/null and b/docs/source/_static/__pycache__/regex-example.cpython-38.pyc differ diff --git a/docs/source/_static/guide-callback-regex-ipdb.py b/docs/source/_static/guide-callback-regex-ipdb.py new file mode 100644 index 0000000..9ca9f9a --- /dev/null +++ b/docs/source/_static/guide-callback-regex-ipdb.py @@ -0,0 +1,20 @@ +import re +import json +import requests +from httpretty import httprettified, HTTPretty + + +@httprettified(verbose=True, allow_net_connect=False) +def test_basic_body(): + + def my_callback(request, url, headers): + body = {} + import ipdb;ipdb.set_trace() + return (200, headers, json.dumps(body)) + + # Match any url via the regular expression + HTTPretty.register_uri(HTTPretty.GET, re.compile(r'.*'), body=my_callback) + HTTPretty.register_uri(HTTPretty.POST, re.compile(r'.*'), body=my_callback) + + # will trigger ipdb + response = requests.post('https://test.com', data=json.dumps({'hello': 'world'})) diff --git a/docs/source/_static/logo.svg b/docs/source/_static/logo.svg new file mode 100644 index 0000000..7b31d61 --- /dev/null +++ b/docs/source/_static/logo.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + diff --git a/docs/source/_static/read-timeout.py b/docs/source/_static/read-timeout.py new file mode 100644 index 0000000..59e668e --- /dev/null +++ b/docs/source/_static/read-timeout.py @@ -0,0 +1,39 @@ +import requests, time +from threading import Event + +from httpretty import httprettified +from httpretty import HTTPretty + + +@httprettified(allow_net_connect=False) +def test_read_timeout(): + event = Event() + wait_seconds = 10 + connect_timeout = 0.1 + read_timeout = 0.1 + + def my_callback(request, url, headers): + event.wait(wait_seconds) + return 200, headers, "Received" + + HTTPretty.register_uri( + HTTPretty.GET, "http://example.com", + body=my_callback + ) + + requested_at = time.time() + try: + requests.get( + "http://example.com", + timeout=(connect_timeout, read_timeout)) + except requests.exceptions.ReadTimeout: + pass + + event_set_at = time.time() + event.set() + + now = time.time() + + assert now - event_set_at < 0.2 + total_duration = now - requested_at + assert total_duration < 0.2 diff --git a/docs/source/_static/regex-example.py b/docs/source/_static/regex-example.py new file mode 100644 index 0000000..72fc3fe --- /dev/null +++ b/docs/source/_static/regex-example.py @@ -0,0 +1,14 @@ +import re +import requests +import httpretty + + +@httpretty.activate(allow_net_connect=False, verbose=True) +def test_regex(): + httpretty.register_uri(httpretty.GET, re.compile(r'.*'), status=418) + + response1 = requests.get('http://foo.com') + assert response1.status_code == 418 + + response2 = requests.get('http://test.com') + assert response2.status_code == 418 diff --git a/docs/source/_static/tmplun_dcms-ascii.cast b/docs/source/_static/tmplun_dcms-ascii.cast new file mode 100644 index 0000000..6e26bca --- /dev/null +++ b/docs/source/_static/tmplun_dcms-ascii.cast @@ -0,0 +1,235 @@ +{"version": 2, "width": 115, "height": 30, "timestamp": 1621882710, "env": {"SHELL": "/usr/local/bin/bash", "TERM": "xterm-256color"}} +[1.115441, "o", "HTTPretty $ "] +[1.648012, "o", "."] +[1.67985, "o", "v"] +[1.767955, "o", "e"] +[2.045646, "o", "nv/"] +[2.888016, "o", "b"] +[2.960179, "o", "i"] +[3.046058, "o", "n/"] +[4.703758, "o", "o"] +[5.007876, "o", "\b\u001b[K"] +[5.183768, "o", "n"] +[5.249014, "o", "o"] +[5.286233, "o", "s"] +[5.423938, "o", "e"] +[5.553841, "o", "\u0007tests"] +[6.562148, "o", " "] +[6.680246, "o", "d"] +[6.791965, "o", "o"] +[6.880409, "o", "cs/"] +[7.533022, "o", "s"] +[7.596079, "o", "ource/"] +[7.636732, "o", "o"] +[7.728134, "o", "u"] +[8.176255, "o", "\b\u001b[K"] +[8.321962, "o", "\b\u001b[K"] +[8.463491, "o", "_"] +[8.584109, "o", "s"] +[8.727909, "o", "t"] +[8.780703, "o", "atic/"] +[9.403611, "o", "\u0007"] +[10.393067, "o", "\r\n"] +[10.393402, "o", "__pycache__/ guide-callback-regex-ipdb.py logo.svg\r\nHTTPretty $ .venv/bin/nosetests docs/source/_static/"] +[10.517051, "o", "\r\n"] +[10.517371, "o", "__pycache__/ guide-callback-regex-ipdb.py logo.svg\r\nHTTPretty $ .venv/bin/nosetests docs/source/_static/"] +[11.423948, "o", "g"] +[11.532343, "o", "uide-callback-regex-ipdb.py "] +[14.504165, "o", "\r\n"] +[16.477912, "o", "#190 guide-callback-regex-ipdb.test_basic_body ... "] +[18.01153, "o", "> \u001b[0;32m/Users/gabrielfalcao/projects/personal/HTTPretty/docs/source/_static/guide-callback-regex-ipdb.py\u001b[0m(13)\u001b[0;36mmy_callback\u001b[0;34m()\u001b[0m\r\n\u001b[0;32m 12 \u001b[0;31m \u001b[0;32mimport\u001b[0m \u001b[0mipdb\u001b[0m\u001b[0;34m;\u001b[0m\u001b[0mipdb\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mset_trace\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[0m\u001b[0;32m---> 13 \u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0;34m(\u001b[0m\u001b[0;36m200\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mheaders\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mjson\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdumps\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mbody\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[0m\u001b[0;32m 14 \u001b[0;31m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[0m\r\n"] +[18.014165, "o", "\u001b[?1l"] +[18.014537, "o", "\u001b[6n"] +[18.030527, "o", "\u001b[?2004h\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[6D\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[18.071481, "o", "\u001b[?25l\u001b[?7l\u001b[6D\u001b[0;38;5;28mipdb> \u001b[0m\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\u001b[0m \b\u001b[22A\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[21.666012, "o", "\u001b[?25l\u001b[?7l\u001b[0ml\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[21.879456, "o", "\u001b[?25l\u001b[?7l\u001b[7D\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[0ml\u001b[7D\u001b[0m\r\r\n\u001b[J\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h\u001b[?2004l"] +[21.882208, "o", "\u001b[1;32m 8 \u001b[0m\u001b[0;32mdef\u001b[0m \u001b[0mtest_basic_body\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[1;32m 9 \u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[1;32m 10 \u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mmy_callback\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mrequest\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0murl\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mheaders\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[1;32m 11 \u001b[0m \u001b[0mbody\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m{\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[1;32m 12 \u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mipdb\u001b[0m\u001b[0;34m;\u001b[0m\u001b[0mipdb\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mset_trace\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[0;32m---> 13 \u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0;34m(\u001b[0m\u001b[0;36m200\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mheaders\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mjson\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdumps\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mbody\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[0m\u001b[1;32m 14 \u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[1;32m 15 \u001b[0m \u001b[0;31m# Match any url via the regular expression\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[1;32m 16 \u001b[0m \u001b[0mHTTPrett"] +[21.882467, "o", "y\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregister_uri\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mHTTPretty\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mGET\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mre\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcompile\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mr'.*'\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mbody\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mmy_callback\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[1;32m 17 \u001b[0m \u001b[0mHTTPretty\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregister_uri\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mHTTPretty\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mPOST\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mre\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcompile\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mr'.*'\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mbody\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mmy_callback\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\u001b[1;32m 18 \u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\r\n\r\n"] +[21.883476, "o", "\u001b[?1l"] +[21.883604, "o", "\u001b[6n"] +[21.887032, "o", "\u001b[?2004h\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[6D\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[21.892331, "o", "\u001b[?25l\u001b[?7l\u001b[6D\u001b[0;38;5;28mipdb> \u001b[0m\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\u001b[0m \b\u001b[9A\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[22.81778, "o", "\u001b[?25l\u001b[?7l\u001b[0mp\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[22.969469, "o", "\u001b[?25l\u001b[?7l\u001b[0mp\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[23.181313, "o", "\u001b[?25l\u001b[?7l\u001b[C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[23.322972, "o", "\u001b[?25l\u001b[?7l\u001b[0mr\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[23.378349, "o", "\u001b[?25l\u001b[?7l\u001b[0me\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[23.544925, "o", "\u001b[?25l\u001b[?7l\u001b[0mq\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[23.665954, "o", "\u001b[?25l\u001b[?7l\u001b[0mu\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[23.741902, "o", "\u001b[?25l\u001b[?7l\u001b[0me\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[23.879494, "o", "\u001b[?25l\u001b[?7l\u001b[0ms\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[24.000226, "o", "\u001b[?25l\u001b[?7l\u001b[0mt\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[24.365902, "o", "\u001b[?25l\u001b[?7l\u001b[16D\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[0mpp request\u001b[16D\u001b[0m\r\r\n\u001b[J\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h\u001b[?2004l"] +[24.367405, "o", "\r\n"] +[24.368531, "o", "\u001b[?1l"] +[24.368635, "o", "\u001b[6n"] +[24.371795, "o", "\u001b[?2004h\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[6D\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[24.376988, "o", "\u001b[?25l\u001b[?7l\u001b[6D\u001b[0;38;5;28mipdb> \u001b[0m\r\r\n\r\r\n\r\r\n\r\r\n\r\r\n\u001b[0m \b\u001b[5A\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[27.359342, "o", "\u001b[?25l\u001b[?7l\u001b[0mpp request\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[27.82273, "o", "\u001b[?25l\u001b[?7l\u001b[0m.\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[28.255766, "o", "\u001b[?25l\u001b[?7l\u001b[0mh\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[28.313307, "o", "\u001b[?25l\u001b[?7l\u001b[0me\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[28.405472, "o", "\u001b[?25l\u001b[?7l\u001b[0ma\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[28.850422, "o", "\u001b[?25l\u001b[?7l\u001b[0md\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[28.912138, "o", "\u001b[?25l\u001b[?7l\u001b[0me\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[29.015229, "o", "\u001b[?25l\u001b[?7l\u001b[0mr\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[29.112081, "o", "\u001b[?25l\u001b[?7l\u001b[0ms\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[29.429921, "o", "\u001b[?25l\u001b[?7l\u001b[24D\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[0mpp request.headers\u001b[24D\u001b[0m\r\r\n\u001b[J\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h\u001b[?2004l"] +[29.431801, "o", "\r\n"] +[29.433016, "o", "\u001b[?1l"] +[29.433238, "o", "\u001b[6n"] +[29.436901, "o", "\u001b[?2004h\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[6D\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[29.444544, "o", "\u001b[?25l\u001b[?7l\u001b[6D\u001b[0;38;5;28mipdb> \u001b[0m\r\r\n\r\r\n\r\r\n\u001b[0m \b\u001b[3A\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[30.156471, "o", "\u001b[?25l\u001b[?7l\u001b[0mpp request.headers\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[30.608228, "o", "\u001b[?25l\u001b[?7l\u001b[7D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[30.765725, "o", "\u001b[?25l\u001b[?7l\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[30.902549, "o", "\u001b[?25l\u001b[?7l\u001b[7D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[31.381908, "o", "\u001b[?25l\u001b[?7l\u001b[0mdrequest.headers\u001b[15D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[31.510943, "o", "\u001b[?25l\u001b[?7l\u001b[0mirequest.headers\u001b[15D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[31.57281, "o", "\u001b[?25l\u001b[?7l\u001b[0mcrequest.headers\u001b[15D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[31.725778, "o", "\u001b[?25l\u001b[?7l\u001b[0mtrequest.headers\u001b[15D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[31.890771, "o", "\u001b[?25l\u001b[?7l\u001b[0m(request.headers\u001b[15D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[32.215385, "o", "\u001b[?25l\u001b[?7l\u001b[15C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[32.495352, "o", "\u001b[?25l\u001b[?7l\u001b[0m)\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[32.983501, "o", "\u001b[?25l\u001b[?7l\u001b[30D\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[0mpp dict(request.headers)\u001b[30D\u001b[0m\r\r\n\u001b[J\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h\u001b[?2004l"] +[32.98525, "o", "{'Accept': '*/*',\r\n 'Accept-Encoding': 'gzip, deflate',\r\n 'Connection': 'keep-alive',\r\n 'Content-Length': '18',\r\n 'Host': 'test.com',\r\n 'User-Agent': 'python-requests/2.25.1'}\r\n"] +[32.986518, "o", "\u001b[?1l"] +[32.986746, "o", "\u001b[6n"] +[32.990263, "o", "\u001b[?2004h\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[6D\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[32.996419, "o", "\u001b[?25l\u001b[?7l\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[35.671881, "o", "\u001b[?25l\u001b[?7l\u001b[0mp\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[35.856772, "o", "\u001b[?25l\u001b[?7l\u001b[0mp\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[37.215658, "o", "\u001b[?25l\u001b[?7l\u001b[C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[37.693648, "o", "\u001b[?25l\u001b[?7l\u001b[0mr\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[37.758114, "o", "\u001b[?25l\u001b[?7l\u001b[0me\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[38.110067, "o", "\u001b[?25l\u001b[?7l\b\u001b[0m\u001b[K\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[38.247765, "o", "\u001b[?25l\u001b[?7l\u001b[2D\u001b[0m\u001b[K\u001b[C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[38.397251, "o", "\u001b[?25l\u001b[?7l\u001b[0mh\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[38.444177, "o", "\u001b[?25l\u001b[?7l\u001b[0me\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[38.561504, "o", "\u001b[?25l\u001b[?7l\u001b[0ma\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[38.790802, "o", "\u001b[?25l\u001b[?7l\u001b[0md\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[38.926065, "o", "\u001b[?25l\u001b[?7l\u001b[0me\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[39.04009, "o", "\u001b[?25l\u001b[?7l\u001b[0mr\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[39.140742, "o", "\u001b[?25l\u001b[?7l\u001b[0ms\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[39.437672, "o", "\u001b[?25l\u001b[?7l\u001b[C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[39.886449, "o", "\u001b[?25l\u001b[?7l\u001b[17D\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[0mpp headers\u001b[16D\u001b[0m\r\r\n\u001b[J\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h\u001b[?2004l"] +[39.888255, "o", "{'connection': 'close',\r\n 'date': 'Mon, 24 May 2021 18:58:47 GMT',\r\n 'server': 'Python/HTTPretty',\r\n 'status': 200}\r\n"] +[39.890098, "o", "\u001b[?1l"] +[39.890285, "o", "\u001b[6n"] +[39.894169, "o", "\u001b[?2004h\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[6D\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[39.898533, "o", "\u001b[?25l\u001b[?7l\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[45.717659, "o", "\u001b[?25l\u001b[?7l\u001b[0mh\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[45.733999, "o", "\u001b[?25l\u001b[?7l\u001b[0me\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[45.856076, "o", "\u001b[?25l\u001b[?7l\u001b[0ma\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[45.94174, "o", "\u001b[?25l\u001b[?7l\u001b[0md\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[46.030913, "o", "\u001b[?25l\u001b[?7l\u001b[0me\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[46.118401, "o", "\u001b[?25l\u001b[?7l\u001b[0mr\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[46.205205, "o", "\u001b[?25l\u001b[?7l\u001b[0ms\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[46.470445, "o", "\u001b[?25l\u001b[?7l\u001b[0m[\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[46.589064, "o", "\u001b[?25l\u001b[?7l\u001b[0m]\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[46.916657, "o", "\u001b[?25l\u001b[?7l\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[47.326705, "o", "\u001b[?25l\u001b[?7l\u001b[0m'']\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[47.354431, "o", "\u001b[?25l\u001b[?7l\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[47.384967, "o", "\u001b[?25l\u001b[?7l\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[47.47368, "o", "\u001b[?25l\u001b[?7l\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[48.062051, "o", "\u001b[?25l\u001b[?7l\u001b[C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[48.345923, "o", "\u001b[?25l\u001b[?7l\u001b[0mC']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[48.59897, "o", "\u001b[?25l\u001b[?7l\u001b[0mo']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[48.663284, "o", "\u001b[?25l\u001b[?7l\u001b[0mn']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[48.750459, "o", "\u001b[?25l\u001b[?7l\u001b[0mt']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[48.809164, "o", "\u001b[?25l\u001b[?7l\u001b[0me']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[48.888522, "o", "\u001b[?25l\u001b[?7l\u001b[0mn']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[48.949883, "o", "\u001b[?25l\u001b[?7l\u001b[0mt']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[49.088369, "o", "\u001b[?25l\u001b[?7l\u001b[0m-']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[49.265468, "o", "\u001b[?25l\u001b[?7l\u001b[0mT']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[49.450521, "o", "\u001b[?25l\u001b[?7l\u001b[0my']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[49.516735, "o", "\u001b[?25l\u001b[?7l\u001b[0mp']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[49.662079, "o", "\u001b[?25l\u001b[?7l\u001b[0me']\u001b[2D\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[50.01415, "o", "\u001b[?25l\u001b[?7l\u001b[2C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[50.444668, "o", "\u001b[?25l\u001b[?7l\u001b[C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[50.506213, "o", "\u001b[?25l\u001b[?7l\u001b[0m=\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[50.566841, "o", "\u001b[?25l\u001b[?7l\u001b[C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[51.624083, "o", "\u001b[?25l\u001b[?7l\u001b[0m'\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[51.970211, "o", "\u001b[?25l\u001b[?7l\u001b[0m'\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[52.078158, "o", "\u001b[?25l\u001b[?7l\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[52.254808, "o", "\u001b[?25l\u001b[?7l\u001b[0ma'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[52.349951, "o", "\u001b[?25l\u001b[?7l\u001b[0mp'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[52.515528, "o", "\u001b[?25l\u001b[?7l\u001b[0mp'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[52.629864, "o", "\u001b[?25l\u001b[?7l\u001b[0ml'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[52.8266, "o", "\u001b[?25l\u001b[?7l\u001b[0mi'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[52.912236, "o", "\u001b[?25l\u001b[?7l\u001b[0mc'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[52.96185, "o", "\u001b[?25l\u001b[?7l\u001b[0ma'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[53.099131, "o", "\u001b[?25l\u001b[?7l\u001b[0mt'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[53.185005, "o", "\u001b[?25l\u001b[?7l\u001b[0mio'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[53.254551, "o", "\u001b[?25l\u001b[?7l\u001b[0mn'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[53.439432, "o", "\u001b[?25l\u001b[?7l\u001b[0m/'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[53.67937, "o", "\u001b[?25l\u001b[?7l\u001b[0mj'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[53.792533, "o", "\u001b[?25l\u001b[?7l\u001b[0ms'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[53.843024, "o", "\u001b[?25l\u001b[?7l\u001b[0mo'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[53.905637, "o", "\u001b[?25l\u001b[?7l\u001b[0mn'\b\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[54.32111, "o", "\u001b[?25l\u001b[?7l\u001b[49D\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[0mheaders['Content-Type'] = 'application/json'\u001b[50D\u001b[0m\r\r\n\u001b[J\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h\u001b[?2004l"] +[54.323453, "o", "\u001b[?1l"] +[54.323703, "o", "\u001b[6n"] +[54.32769, "o", "\u001b[?2004h\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[6D\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[54.33208, "o", "\u001b[?25l\u001b[?7l\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[55.431101, "o", "\u001b[?25l\u001b[?7l\u001b[0mb\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[55.502203, "o", "\u001b[?25l\u001b[?7l\u001b[0mo\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[55.577597, "o", "\u001b[?25l\u001b[?7l\u001b[0md\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[55.702116, "o", "\u001b[?25l\u001b[?7l\u001b[0my\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[55.887196, "o", "\u001b[?25l\u001b[?7l\u001b[10D\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[0mbody\u001b[10D\u001b[0m\r\r\n\u001b[J\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[55.887473, "o", "\u001b[?2004l"] +[55.888628, "o", "{}\r\n"] +[55.889744, "o", "\u001b[?1l"] +[55.889857, "o", "\u001b[6n"] +[55.893146, "o", "\u001b[?2004h\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[6D\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[55.89828, "o", "\u001b[?25l\u001b[?7l\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[56.247068, "o", "\u001b[?25l\u001b[?7l\u001b[0mb\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[56.319912, "o", "\u001b[?25l\u001b[?7l\u001b[0mo\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[56.448876, "o", "\u001b[?25l\u001b[?7l\u001b[0md\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[56.521946, "o", "\u001b[?25l\u001b[?7l\u001b[0my\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[56.870316, "o", "\u001b[?25l\u001b[?7l\u001b[0m=\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[57.286911, "o", "\u001b[?25l\u001b[?7l\b\u001b[0m\u001b[K\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[57.598176, "o", "\u001b[?25l\u001b[?7l\u001b[0m[\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[58.044934, "o", "\u001b[?25l\u001b[?7l\u001b[0m\"\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[58.398262, "o", "\u001b[?25l\u001b[?7l\u001b[0mo\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[58.568438, "o", "\u001b[?25l\u001b[?7l\u001b[0mo\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[58.814278, "o", "\u001b[?25l\u001b[?7l\b\u001b[0m\u001b[K\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[58.98533, "o", "\u001b[?25l\u001b[?7l\b\u001b[0m\u001b[K\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[59.183923, "o", "\u001b[?25l\u001b[?7l\b\u001b[0m\u001b[K\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[60.874043, "o", "\u001b[?25l\u001b[?7l\u001b[0m\"\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[61.07425, "o", "\u001b[?25l\u001b[?7l\u001b[0mf\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[61.154785, "o", "\u001b[?25l\u001b[?7l\u001b[0mo\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[61.28069, "o", "\u001b[?25l\u001b[?7l\u001b[0mo\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[61.831178, "o", "\u001b[?25l\u001b[?7l\u001b[0m\"\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[61.927243, "o", "\u001b[?25l\u001b[?7l\u001b[0m]\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[62.205321, "o", "\u001b[?25l\u001b[?7l\u001b[C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[62.288705, "o", "\u001b[?25l\u001b[?7l\u001b[0m=\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[62.381831, "o", "\u001b[?25l\u001b[?7l\u001b[C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[62.827726, "o", "\u001b[?25l\u001b[?7l\u001b[0m\"\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[62.999464, "o", "\u001b[?25l\u001b[?7l\u001b[0mb\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[63.070687, "o", "\u001b[?25l\u001b[?7l\u001b[0ma\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[63.157669, "o", "\u001b[?25l\u001b[?7l\u001b[0mr\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[63.421688, "o", "\u001b[?25l\u001b[?7l\u001b[0m\"\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[63.710915, "o", "\u001b[?25l\u001b[?7l\u001b[25D\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[0mbody[\"foo\"] = \"bar\"\u001b[25D\u001b[0m\r\r\n\u001b[J\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[63.711171, "o", "\u001b[?2004l"] +[63.713816, "o", "\u001b[?1l"] +[63.71398, "o", "\u001b[6n"] +[63.717705, "o", "\u001b[?2004h\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[6D\u001b[6C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[63.721978, "o", "\u001b[?25l\u001b[?7l\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[64.750191, "o", "\u001b[?25l\u001b[?7l\u001b[0mc\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[65.540761, "o", "\u001b[?25l\u001b[?7l\u001b[7D\u001b[0m\u001b[J\u001b[0;38;5;28mipdb> \u001b[0mc\u001b[7D\u001b[0m\r\r\n\u001b[J\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] +[65.541109, "o", "\u001b[?2004l"] +[65.544155, "o", "\u001b[32mpassed\u001b[0m\r\n"] +[65.54465, "o", "\r\n"] +[65.849412, "o", "Name Stmts Miss Branch BrPart Cover\r\n---------------------------------------------------------\r\n"] +[65.849701, "o", "httpretty/__init__.py 37 2 0 0 95%\r\nhttpretty/compat.py 17 2 0 0 88%\r\nhttpretty/core.py 1007 200 304 59 76%\r\nhttpretty/errors.py 16 2 6 3 77%\r\nhttpretty/http.py 28 0 4 1 97%\r\nhttpretty/utils.py 8 0 4 0 100%\r\nhttpretty/version.py 1 0 0 0 100%\r\n---------------------------------------------------------\r\nTOTAL 1114 206 318 63 77%\r\n"] +[66.339561, "o", "\u001b[30m-----------------------------------------------------------------------------\u001b[0m\r\n"] +[66.33975, "o", "1 test run in 49.067 seconds\u001b[32m (1 test passed)\u001b[0m\r\n"] +[66.340776, "o", "Error in atexit._run_exitfuncs:\r\nTraceback (most recent call last):\r\n"] +[66.340975, "o", " File \"/Users/gabrielfalcao/projects/personal/HTTPretty/.venv/lib/python3.8/site-packages/IPython/core/history.py\", line 780, in writeout_cache\r\n"] +[66.342214, "o", " self._writeout_input_cache(conn)\r\n"] +[66.342401, "o", " File \"/Users/gabrielfalcao/projects/personal/HTTPretty/.venv/lib/python3.8/site-packages/IPython/core/history.py\", line 763, in _writeout_input_cache\r\n"] +[66.343137, "o", " conn.execute(\"INSERT INTO history VALUES (?, ?, ?, ?)\",\r\nsqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread. The object was created in thread id 123145518436352 and this is thread id 4682546624.\r\n"] +[66.624442, "o", "HTTPretty $ "] +[67.88294, "o", "exit\r\n"] diff --git a/docs/source/acks.rst b/docs/source/acks.rst new file mode 100644 index 0000000..036b795 --- /dev/null +++ b/docs/source/acks.rst @@ -0,0 +1,21 @@ +Acknowledgements +################ + +Caveats +======= + +``forcing_headers`` + ``Content-Length`` +---------------------------------------- + +When using the ``forcing_headers`` option make sure to add the header +``Content-Length`` otherwise calls using :py:mod:`requests` will try +to load the response endlessly. + +Supported Libraries +------------------- + +Because HTTPretty works in the socket level it should work with any HTTP client libraries, although it is `battle tested `_ against: + +* `requests `_ +* `httplib2 `_ +* `urllib2 `_ diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..71fe992 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,191 @@ +.. _api: + +API Reference +============= + +.. _httpretty: + +register_uri +------------ + +.. automethod:: httpretty.core.httpretty.register_uri + :noindex: + +enable +------ + +.. automethod:: httpretty.core.httpretty.enable + :noindex: + +disable +------- + +.. automethod:: httpretty.core.httpretty.disable + :noindex: + +is_enabled +---------- + +.. automethod:: httpretty.core.httpretty.is_enabled + :noindex: + +last_request +------------ + +.. autofunction:: httpretty.last_request + :noindex: + +latest_requests +--------------- + +.. autofunction:: httpretty.latest_requests + :noindex: + + +.. automodule:: httpretty + +.. _activate: + +activate +-------- + +.. autoclass:: httpretty.activate + :members: + :noindex: + + +.. _httprettified: + +httprettified +------------- + +.. autofunction:: httpretty.core.httprettified + :noindex: + + +.. _enabled: + +enabled +------- + +.. autoclass:: httpretty.enabled + :members: + :noindex: + + +.. _httprettized: + +httprettized +------------ + +.. autoclass:: httpretty.core.httprettized + :members: + :noindex: + + + +.. _HTTPrettyRequest: + +HTTPrettyRequest +---------------- + +.. autoclass:: httpretty.core.HTTPrettyRequest + :members: + :noindex: + + +.. _HTTPrettyRequestEmpty: + +HTTPrettyRequestEmpty +--------------------- + +.. autoclass:: httpretty.core.HTTPrettyRequestEmpty + :members: + :noindex: + +.. _FakeSockFile: + +FakeSockFile +------------ + +.. autoclass:: httpretty.core.FakeSockFile + :members: + :noindex: + + +.. _FakeSSLSocket: + +FakeSSLSocket +------------- + +.. autoclass:: httpretty.core.FakeSSLSocket + :members: + :noindex: + + +.. _URIInfo: + +URIInfo +------- + +.. autoclass:: httpretty.URIInfo + :members: + :noindex: + + +.. _URIMatcher: + +URIMatcher +---------- + +.. autoclass:: httpretty.URIMatcher + :members: + :noindex: + + +.. _Entry: + +Entry +----- + +.. autoclass:: httpretty.Entry + :members: + :noindex: + + +.. _api modules: + +Modules +======= + +.. _api module core: + +Core +---- + +.. automodule:: httpretty.core + :members: + +.. _api module http: + +Http +---- + +.. automodule:: httpretty.http + :members: + +.. _api module utils: + +Utils +----- + +.. automodule:: httpretty.utils + :members: + +.. _api module exceptions: + +Exceptions +---------- + +.. automodule:: httpretty.errors + :members: diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst new file mode 100644 index 0000000..c1386ca --- /dev/null +++ b/docs/source/changelog.rst @@ -0,0 +1,212 @@ +Release Notes +============= + +Release 1.1.4 +------------- + +- Bugfix: `#435 `_ Fallback to WARNING when logging.getLogger().level is None. + +Release 1.1.3 +------------- + +- Bugfix: `#430 `_ Respect socket timeout. + +Release 1.1.2 +------------- + +- Bugfix: `#426 `_ Segmentation fault when running against a large amount of tests with ``pytest --mypy``. + +Release 1.1.1 +------------- + +- Bugfix: `httpretty.disable()` injects pyopenssl into :py:mod:`urllib3` even if it originally wasn't `#417 `_ +- Bugfix: "Incompatibility with boto3 S3 put_object" `#416 `_ +- Bugfix: "Regular expression for URL -> TypeError: wrap_socket() missing 1 required" `#413 `_ +- Bugfix: "Making requests to non-stadard port throws TimeoutError "`#387 `_ + + +Release 1.1.0 +------------- + +- Feature: Display mismatched URL within ``UnmockedError`` whenever possible. `#388 `_ +- Feature: Display mismatched URL via logging. `#419 `_ +- Add new properties to :py:class:`httpretty.core.HTTPrettyRequest` (``protocol, host, url, path, method``). + +Example usage: + +.. testcode:: + + import httpretty + import requests + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_mismatches(): + requests.get('http://sql-server.local') + requests.get('https://redis.local') + + +Release 1.0.5 +------------- + +- Bugfix: Support `socket.socketpair() `_ . `#402 `_ +- Bugfix: Prevent exceptions from re-applying monkey patches. `#406 `_ + +Release 1.0.4 +------------- + +- Python 3.8 and 3.9 support. `#407 `_ + +Release 1.0.3 +------------- + +- Fix compatibility with urllib3>=1.26. `#410 `_ + +Release 1.0.0 +------------- + +- Drop Python 2 support. +- Fix usage with redis and improve overall real-socket passthrough. `#271 `_. +- Fix TypeError: wrap_socket() missing 1 required positional argument: 'sock' (`#393 `_) +- Merge pull request `#364 `_ +- Merge pull request `#371 `_ +- Merge pull request `#379 `_ +- Merge pull request `#386 `_ +- Merge pull request `#302 `_ +- Merge pull request `#373 `_ +- Merge pull request `#383 `_ +- Merge pull request `#385 `_ +- Merge pull request `#389 `_ +- Merge pull request `#391 `_ +- Fix simple typo: neighter -> neither. +- Updated documentation for register_uri concerning using ports. +- Clarify relation between ``enabled`` and ``httprettized`` in API docs. +- Align signature with builtin socket. + +Release 0.9.4 +------------- + +Improvements: + +- Official Python 3.6 support +- Normalized coding style to comform with PEP8 (partially) +- Add more API reference coverage in docstrings of members such as :py:class:`httpretty.core.Entry` +- Continuous Integration building python 2.7 and 3.6 +- Migrate from `pip `_ to `pipenv `_ + + +Release 0.8.4 +------------- + +Improvements: + +- Refactored ``core.py`` and increased its unit test coverage to 80%. + HTTPretty is slightly more robust now. + +Bug fixes: + +- POST requests being called twice + `#100 `__ + +Release 0.6.5 +------------- + +Applied pull requests: + +- continue on EAGAIN socket errors: + `#102 `__ by + `kouk `__. +- Fix ``fake_gethostbyname`` for requests 2.0: + `#101 `__ by + `mgood `__ +- Add a way to match the querystrings: + `#98 `__ by + `ametaireau `__ +- Use common string case for URIInfo hostname comparison: + `#95 `__ by + `mikewaters `__ +- Expose httpretty.reset() to public API: + `#91 `__ by + `imankulov `__ +- Don't duplicate http ports number: + `#89 `__ by + `mardiros `__ +- Adding parsed\_body parameter to simplify checks: + `#88 `__ by + `toumorokoshi `__ +- Use the real socket if it's not HTTP: + `#87 `__ by + `mardiros `__ + +Release 0.6.2 +------------- + +- Fixing bug of lack of trailing slashes + `#73 `__ +- Applied pull requests + `#71 `__ and + `#72 `__ by + @andresriancho +- Keyword arg coercion fix by @dupuy +- @papaeye fixed content-length calculation. + +Release 0.6.1 +------------- + +- New API, no more camel case and everything is available through a + simple import: + +.. code:: python + + import httpretty + + @httpretty.activate + def test_function(): + # httpretty.register_uri(...) + # make request... + pass + +- Re-organized module into submodules + +Release 0.5.14 +-------------- + +- Delegate calls to other methods on socket + +- `Normalized + header `__ + strings + +- Callbacks are `more intelligent + now `__ + +- Normalize urls matching for url quoting + +Release 0.5.12 +-------------- + +- HTTPretty doesn't hang when using other application protocols under a + @httprettified decorated test. + +Release 0.5.11 +-------------- + +- Ability to know whether HTTPretty is or not enabled through + ``httpretty.is_enabled()`` + +Release 0.5.10 +-------------- + +- Support to multiple methods per registered URL. Thanks @hughsaunders + +Release 0.5.9 +------------- + +- Fixed python 3 support. Thanks @spulec + +Release 0.5.8 +------------- + +- Support to `register regular expressions to match + urls <#matching-regular-expressions>`__ +- `Body callback <#dynamic-responses-through-callbacks>`__ suppport +- Python 3 support diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..1b47afb --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +import sys +import sphinx_rtd_theme +try: + from pathlib2 import Path +except ImportError: + from pathlib import Path + +project_path = Path(__file__).absolute().parent.joinpath('../..') + +sys.path.insert(0, project_path.as_posix()) + +from httpretty.version import version # noqa + + +project = 'HTTPretty' +copyright = '2011-2021, Gabriel Falcao' +author = 'Gabriel Falcao' + +# The short X.Y version +version = version +# The full version, including alpha/beta/rc tags +release = version + + +extensions = [ + 'sphinx.ext.napoleon', + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.coverage', + 'sphinx.ext.ifconfig', + 'sphinx.ext.viewcode', + 'sphinx.ext.githubpages', + 'sphinx.ext.autosummary', + 'sphinx.ext.autosummary', + 'sphinxcontrib.asciinema', +] + +templates_path = ['_templates'] + +source_suffix = '.rst' +master_doc = 'index' +language = None +exclude_patterns = [] +pygments_style = 'friendly' +html_theme = "sphinx_rtd_theme" +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +# html_theme_options = {} +html_static_path = ['_static'] + +htmlhelp_basename = 'HTTPrettydoc' + +latex_elements = {} + +latex_documents = [ + (master_doc, 'HTTPretty.tex', 'HTTPretty Documentation', + 'Gabriel Falcao', 'manual'), +] + +man_pages = [ + (master_doc, 'httpretty', 'HTTPretty Documentation', + [author], 1) +] + + +texinfo_documents = [ + (master_doc, 'HTTPretty', 'HTTPretty Documentation', + author, 'HTTPretty', 'One line description of project.', + 'Miscellaneous'), +] + + +epub_title = project +epub_author = author +epub_publisher = author +epub_copyright = copyright + +epub_exclude_files = ['search.html'] +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + + 'httplib2': ('https://httplib2.readthedocs.io/en/latest/', None), + 'requests': ('https://requests.readthedocs.io/en/master/', None), + 'urllib3': ('https://urllib3.readthedocs.io/en/latest/', None), +} diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst new file mode 100644 index 0000000..99d390b --- /dev/null +++ b/docs/source/contributing.rst @@ -0,0 +1,71 @@ +Hacking on HTTPretty +==================== + +install development dependencies +-------------------------------- + + +.. note:: HTTPretty uses `GNU Make + `_ as default build + tool. + + +.. code:: bash + + make dependencies + + +next steps +---------- + +1. run the tests with make: + +.. code:: bash + + make tests + +2. hack at will +3. commit, push etc +4. send a pull request + + +License +======= + +:: + + + Copyright (C) <2011-2021> Gabriel Falcão + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + +Main contributors +================= + +HTTPretty has received `many contributions `_ +but some folks made remarkable contributions and deserve extra credit: + +- Andrew Gross ~> `@andrewgross `_ +- Hugh Saunders ~> `@hughsaunders `_ +- James Rowe ~> `@JNRowe `_ +- Matt Luongo ~> `@mhluongo `_ +- Steve Pulec ~> `@spulec `_ diff --git a/docs/source/guides.rst b/docs/source/guides.rst new file mode 100644 index 0000000..6349240 --- /dev/null +++ b/docs/source/guides.rst @@ -0,0 +1,119 @@ +.. _guides: + + +Guides +###### + +A series of guides to using HTTPretty for various interesting +purposes. + + +.. _matching_urls_via_regular_expressions: + +Matching URLs via regular expressions +===================================== + +You can pass a compiled regular expression via :py:func:`re.compile`, +for example for intercepting all requests to a specific host. + +**Example:** + + +.. literalinclude:: _static/regex-example.py + :emphasize-lines: 8,10,13 + + +Response Callbacks +================== + + + +You can use the `body` parameter of +:py:meth:`~httpretty.core.httpretty.register_uri` in useful, practical +ways because it accepts a :py:func:`callable` as value. + +As matter of example, this is analogous to `defining routes in Flask +`_ when combined with :ref:`matching urls via regular expressions ` + +This analogy breaks down, though, because HTTPretty does not provide +tools to make it easy to handle cookies, parse querystrings etc. + +So far this has been a deliberate decision to keep HTTPretty operating +mostly at the TCP socket level. + +Nothing prevents you from being creative with callbacks though, and as +you will see in the examples below, the request parameter is an +instance of :py:class:`~httpretty.core.HTTPrettyRequest` which has +everything you need to create elaborate fake APIs. + + +Defining callbacks +------------------ + +The body parameter callback must: + +- Accept 3 arguments: + + - `request` - :py:class:`~httpretty.core.HTTPrettyRequest` + - `uri` - :py:class:`str` + - `headers` - :py:class:`dict` with default response headers (including the ones from the parameters ``adding_headers`` and ``forcing_headers`` of :py:meth:`~httpretty.core.httpretty.register_uri` + +- Return 3 a tuple (or list) with 3 values + + - :py:class:`int` - HTTP Status Code + - :py:class:`dict` - Response Headers + - :py:class:`st` - Response Body + +.. important:: + The **Content-Length** should match the byte length of the body. + + Changing **Content-Length** it in your handler can cause your HTTP + client to misbehave, be very intentional when modifying it in our + callback. + + The suggested way to manipulate headers is by modifying the + response headers passed as argument and returning them in the tuple + at the end. + + .. code:: python + + from typing import Tuple + from httpretty.core import HTTPrettyRequest + + def my_callback( + request: HTTPrettyRequest, + url: str, + headers: dict + + ) -> Tuple[int, dict, str]: + + headers['Content-Type'] = 'text/plain' + return (200, headers, "the body") + + HTTPretty.register_uri(HTTPretty.GET, "https://test.com", body=my_callback) + + +Debug requests interactively with ipdb +-------------------------------------- + +The library `ipdb `_ comes in handy to +introspect the request interactively with auto-complete via IPython. + +.. literalinclude:: _static/guide-callback-regex-ipdb.py + :emphasize-lines: 12,16,17 + +.. asciinema:: 415981 + :preload: 1 + + +Emulating timeouts +------------------ + +In the bug report `#430 +`_ the contributor `@mariojonke +`_ provided a neat example of how to +emulate read timeout errors by "waiting" inside of a body callback. + + +.. literalinclude:: _static/read-timeout.py + :emphasize-lines: 11-13,21,28 diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..c2a30a6 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,80 @@ +.. HTTPretty documentation master file, created by + sphinx-quickstart on Sun Dec 13 07:25:00 2015. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +HTTPretty's - HTTP Client Mocking for Python +============================================ + +.. image:: https://github.com/gabrielfalcao/HTTPretty/raw/master/docs/source/_static/logo.svg?sanitize=true + +HTTP Client mocking tool for Python created by `Gabriel Falcão `_ . It provides a full fake TCP socket module. Inspired by `FakeWeb `_ + +Looking for the `Github Repository `_ ? + +**Python Support:** + +- **3.6** +- **3.7** +- **3.8** +- **3.9** + +.. image:: https://img.shields.io/pypi/dm/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/codecov/c/github/gabrielfalcao/HTTPretty + :target: https://codecov.io/gh/gabrielfalcao/HTTPretty + +.. image:: https://img.shields.io/github/workflow/status/gabrielfalcao/HTTPretty/HTTPretty%20Tests?label=Python%203.6%20-%203.9 + :target: https://github.com/gabrielfalcao/HTTPretty/actions + +.. image:: https://img.shields.io/readthedocs/httpretty + :target: https://httpretty.readthedocs.io/ + +.. image:: https://img.shields.io/github/license/gabrielfalcao/HTTPretty?label=Github%20License + :target: https://github.com/gabrielfalcao/HTTPretty/blob/master/COPYING + +.. image:: https://img.shields.io/pypi/v/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/l/HTTPretty?label=PyPi%20License + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/format/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/status/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/pyversions/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/pypi/implementation/HTTPretty + :target: https://pypi.org/project/HTTPretty + +.. image:: https://img.shields.io/snyk/vulnerabilities/github/gabrielfalcao/HTTPretty + :target: https://github.com/gabrielfalcao/HTTPretty/network/alerts + +.. image:: https://img.shields.io/github/v/tag/gabrielfalcao/HTTPretty + :target: https://github.com/gabrielfalcao/HTTPretty/releases + +.. |Join the chat at https://gitter.im/gabrielfalcao/HTTPretty| image:: https://badges.gitter.im/gabrielfalcao/HTTPretty.svg + :target: https://gitter.im/gabrielfalcao/HTTPretty?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge + + +.. toctree:: + :maxdepth: 2 + + introduction + guides + acks + api + contributing + changelog + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst new file mode 100644 index 0000000..a5339d2 --- /dev/null +++ b/docs/source/introduction.rst @@ -0,0 +1,182 @@ +.. _introduction: + +`Github `_ + + +What is HTTPretty ? +################### + +.. highlight:: python + +Once upon a time a python developer wanted to use a RESTful api, +everything was fine but until the day he needed to test the code that +hits the RESTful API: what if the API server is down? What if its +content has changed ? + +Don't worry, HTTPretty is here for you: + +:: + + import logging + import requests + import httpretty + + from sure import expect + + logging.getLogger('httpretty.core').setLevel(logging.DEBUG) + + @httpretty.activate(allow_net_connect=False) + def test_yipit_api_returning_deals(): + httpretty.register_uri(httpretty.GET, "http://api.yipit.com/v1/deals/", + body='[{"title": "Test Deal"}]', + content_type="application/json") + + response = requests.get('http://api.yipit.com/v1/deals/') + + expect(response.json()).to.equal([{"title": "Test Deal"}]) + + +A more technical description +============================ + +HTTPretty is a python library that swaps the modules :py:mod:`socket` +and :py:mod:`ssl` with fake implementations that intercept HTTP +requests at the level of a TCP connection. + +It is inspired on Ruby's `FakeWeb `_. + +If you come from the Ruby programming language this would probably sound familiar :smiley: + +Installing +========== + +Installing httpretty is as easy as: + +.. highlight:: bash + +:: + + pip install httpretty + + +Demo +#### + +expecting a simple response body +================================ + + +.. code:: python + + import requests + import httpretty + + def test_one(): + httpretty.enable(verbose=True, allow_net_connect=False) # enable HTTPretty so that it will monkey patch the socket module + httpretty.register_uri(httpretty.GET, "http://yipit.com/", + body="Find the best daily deals") + + response = requests.get('http://yipit.com') + + assert response.text == "Find the best daily deals" + + httpretty.disable() # disable afterwards, so that you will have no problems in code that uses that socket module + httpretty.reset() # reset HTTPretty state (clean up registered urls and request history) + + +making assertions in a callback that generates the response body +================================================================ + +.. code:: python + + import requests + import json + import httpretty + + @httpretty.activate + def test_with_callback_response(): + def request_callback(request, uri, response_headers): + content_type = request.headers.get('Content-Type') + assert request.body == '{"nothing": "here"}', 'unexpected body: {}'.format(request.body) + assert content_type == 'application/json', 'expected application/json but received Content-Type: {}'.format(content_type) + return [200, response_headers, json.dumps({"hello": "world"})] + + httpretty.register_uri( + httpretty.POST, "https://httpretty.example.com/api", + body=request_callback) + + response = requests.post('https://httpretty.example.com/api', headers={'Content-Type': 'application/json'}, data='{"nothing": "here"}') + + expect(response.json()).to.equal({"hello": "world"}) + + +Link headers +============ + + + Tests link headers by using the `adding_headers` parameter. + + + .. code:: python + + import requests + from sure import expect + import httpretty + + + @httpretty.activate + def test_link_response(): + first_url = "http://foo-api.com/data" + second_url = "http://foo-api.com/data?page=2" + link_str = "<%s>; rel='next'" % second_url + + httpretty.register_uri( + httpretty.GET, + first_url, + body='{"success": true}', + status=200, + content_type="text/json", + adding_headers={"Link": link_str}, + ) + httpretty.register_uri( + httpretty.GET, + second_url, + body='{"success": false}', + status=500, + content_type="text/json", + ) + # Performs a request to `first_url` followed by some testing + response = requests.get(first_url) + expect(response.json()).to.equal({"success": True}) + expect(response.status_code).to.equal(200) + next_url = response.links["next"]["url"] + expect(next_url).to.equal(second_url) + + # Follow the next URL and perform some testing. + response2 = requests.get(next_url) + expect(response2.json()).to.equal({"success": False}) + expect(response2.status_code).to.equal(500) + + +Motivation +########## + +When building systems that access external resources such as RESTful +webservices, XMLRPC or even simple HTTP requests, we stumble in the +problem: + + *"I'm gonna need to mock all those requests"* + +It can be a bit of a hassle to use something like +:py:class:`mock.Mock` to stub the requests, this can work well for +low-level unit tests but when writing functional or integration tests +we should be able to allow the http calls to go through the TCP socket +module. + +HTTPretty `monkey patches +`_ Python's +:py:mod:`socket` core module with a fake version of the module. + +Because HTTPretty implements a fake the modules :py:mod:`socket` and +:py:mod:`ssl` you can use write tests to code against any HTTP library +that use those modules. diff --git a/httpretty.egg-info/PKG-INFO b/httpretty.egg-info/PKG-INFO new file mode 100644 index 0000000..df3fece --- /dev/null +++ b/httpretty.egg-info/PKG-INFO @@ -0,0 +1,190 @@ +Metadata-Version: 1.2 +Name: httpretty +Version: 1.1.4 +Summary: HTTP client mock for Python +Home-page: https://httpretty.readthedocs.io/en/latest/ +Author: Gabriel Falcao +Author-email: gabriel@nacaolivre.org +License: MIT +Project-URL: Documentation, https://httpretty.readthedocs.io/en/latest/ +Project-URL: Source Code, https://github.com/gabrielfalcao/httpretty +Project-URL: Issue Tracker, https://github.com/gabrielfalcao/httpretty/issues +Project-URL: Continuous Integration, https://github.com/gabrielfalcao/HTTPretty/actions/workflows/pyenv.yml?query=branch%3Amaster+event%3Apush +Project-URL: Test Coverage, https://codecov.io/gh/gabrielfalcao/httpretty +Description: HTTPretty 1.1.4 + =============== + + .. image:: https://github.com/gabrielfalcao/HTTPretty/raw/master/docs/source/_static/logo.svg?sanitize=true + + HTTP Client mocking tool for Python created by `Gabriel Falcão `_ . It provides a full fake TCP socket module. Inspired by `FakeWeb `_ + + + - `Github Repository `_ + - `Documentation `_ + - `PyPI Package `_ + + + **Python Support:** + + - **3.6** + - **3.7** + - **3.8** + - **3.9** + + .. image:: https://img.shields.io/pypi/dm/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/codecov/c/github/gabrielfalcao/HTTPretty + :target: https://codecov.io/gh/gabrielfalcao/HTTPretty + + .. image:: https://img.shields.io/github/workflow/status/gabrielfalcao/HTTPretty/HTTPretty%20Tests?label=Python%203.6%20-%203.9 + :target: https://github.com/gabrielfalcao/HTTPretty/actions + + .. image:: https://img.shields.io/readthedocs/httpretty + :target: https://httpretty.readthedocs.io/ + + .. image:: https://img.shields.io/github/license/gabrielfalcao/HTTPretty?label=Github%20License + :target: https://github.com/gabrielfalcao/HTTPretty/blob/master/COPYING + + .. image:: https://img.shields.io/pypi/v/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/l/HTTPretty?label=PyPi%20License + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/format/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/status/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/pyversions/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/pypi/implementation/HTTPretty + :target: https://pypi.org/project/HTTPretty + + .. image:: https://img.shields.io/snyk/vulnerabilities/github/gabrielfalcao/HTTPretty + :target: https://github.com/gabrielfalcao/HTTPretty/network/alerts + + .. image:: https://img.shields.io/github/v/tag/gabrielfalcao/HTTPretty + :target: https://github.com/gabrielfalcao/HTTPretty/releases + + .. |Join the chat at https://gitter.im/gabrielfalcao/HTTPretty| image:: https://badges.gitter.im/gabrielfalcao/HTTPretty.svg + :target: https://gitter.im/gabrielfalcao/HTTPretty?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge + + Install + ------- + + .. code:: bash + + pip install httpretty + + + + Common Use Cases + ================ + + - Test-driven development of API integrations + - Fake responses of external APIs + - Record and playback HTTP requests + + + Simple Example + -------------- + + .. code:: python + + import sure + import httpretty + import requests + + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_httpbin(): + httpretty.register_uri( + httpretty.GET, + "https://httpbin.org/ip", + body='{"origin": "127.0.0.1"}' + ) + + response = requests.get('https://httpbin.org/ip') + response.json().should.equal({'origin': '127.0.0.1'}) + + httpretty.latest_requests().should.have.length_of(1) + httpretty.last_request().should.equal(httpretty.latest_requests()[0]) + httpretty.last_request().body.should.equal('{"origin": "127.0.0.1"}') + + + checking multiple responses + --------------------------- + + .. code:: python + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_post_bodies(): + url = 'http://httpbin.org/post' + httpretty.register_uri(httpretty.POST, url, status=200) + httpretty.register_uri(httpretty.POST, url, status=400) + requests.post(url, data={'foo': 'bar'}) + requests.post(url, data={'zoo': 'zoo'}) + assert 'foo=bar' in httpretty.latest_requests()[0].body + assert 'zoo=bar' in httpretty.latest_requests()[1].body + + + License + ======= + + :: + + + Copyright (C) <2011-2021> Gabriel Falcão + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + Main contributors + ================= + + HTTPretty has received `many contributions `_ + but some folks made remarkable contributions and deserve extra credit: + + - Andrew Gross ~> `@andrewgross `_ + - Hugh Saunders ~> `@hughsaunders `_ + - James Rowe ~> `@JNRowe `_ + - Matt Luongo ~> `@mhluongo `_ + - Steve Pulec ~> `@spulec `_ + - Miro Hrončok ~> `@hroncok `_ + Mario Jonke ~> `@mariojonke `_ + +Platform: UNKNOWN +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python +Classifier: Topic :: Internet :: WWW/HTTP +Classifier: Topic :: Software Development :: Testing +Requires-Python: >=3 diff --git a/httpretty.egg-info/SOURCES.txt b/httpretty.egg-info/SOURCES.txt new file mode 100644 index 0000000..9f47a71 --- /dev/null +++ b/httpretty.egg-info/SOURCES.txt @@ -0,0 +1,82 @@ +COPYING +MANIFEST.in +Makefile +README.rst +development.txt +requirements.txt +setup.cfg +setup.py +tox.ini +docs/make.bat +docs/build/doctrees/acks.doctree +docs/build/doctrees/api.doctree +docs/build/doctrees/changelog.doctree +docs/build/doctrees/contributing.doctree +docs/build/doctrees/environment.pickle +docs/build/doctrees/guides.doctree +docs/build/doctrees/index.doctree +docs/build/doctrees/introduction.doctree +docs/source/acks.rst +docs/source/api.rst +docs/source/changelog.rst +docs/source/conf.py +docs/source/contributing.rst +docs/source/guides.rst +docs/source/index.rst +docs/source/introduction.rst +docs/source/_static/guide-callback-regex-ipdb.py +docs/source/_static/logo.svg +docs/source/_static/read-timeout.py +docs/source/_static/regex-example.py +docs/source/_static/tmplun_dcms-ascii.cast +docs/source/_static/__pycache__/guide-callback-regex-ipdb.cpython-38.pyc +docs/source/_static/__pycache__/read-timeout.cpython-38-pytest-6.2.4.pyc +docs/source/_static/__pycache__/read-timeout.cpython-38.pyc +docs/source/_static/__pycache__/regex-example.cpython-38.pyc +httpretty/__init__.py +httpretty/compat.py +httpretty/core.py +httpretty/errors.py +httpretty/http.py +httpretty/utils.py +httpretty/version.py +httpretty.egg-info/PKG-INFO +httpretty.egg-info/SOURCES.txt +httpretty.egg-info/dependency_links.txt +httpretty.egg-info/not-zip-safe +httpretty.egg-info/top_level.txt +tests/__init__.py +tests/compat.py +tests/bugfixes/nosetests/__init__.py +tests/bugfixes/nosetests/test_242_ssl_bad_handshake.py +tests/bugfixes/nosetests/test_387_regex_port.py +tests/bugfixes/nosetests/test_388_unmocked_error_with_url.py +tests/bugfixes/nosetests/test_413_regex.py +tests/bugfixes/nosetests/test_414_httpx.py +tests/bugfixes/nosetests/test_416_boto3.py +tests/bugfixes/nosetests/test_417_openssl.py +tests/bugfixes/nosetests/test_425_latest_requests.py +tests/bugfixes/nosetests/test_430_respect_timeout.py +tests/bugfixes/nosetests/test_eventlet.py +tests/bugfixes/nosetests/test_redis.py +tests/bugfixes/nosetests/test_tornado_bind_unused_port.py +tests/bugfixes/pytest/test_426_mypy_segfault.py +tests/functional/__init__.py +tests/functional/base.py +tests/functional/test_bypass.py +tests/functional/test_debug.py +tests/functional/test_decorator.py +tests/functional/test_fakesocket.py +tests/functional/test_httplib2.py +tests/functional/test_passthrough.py +tests/functional/test_requests.py +tests/functional/test_urllib2.py +tests/functional/testserver.py +tests/functional/fixtures/playback-1.json +tests/pyopenssl/__init__.py +tests/pyopenssl/test_mock.py +tests/unit/__init__.py +tests/unit/test_core.py +tests/unit/test_http.py +tests/unit/test_httpretty.py +tests/unit/test_main.py \ No newline at end of file diff --git a/httpretty.egg-info/dependency_links.txt b/httpretty.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/httpretty.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/httpretty.egg-info/not-zip-safe b/httpretty.egg-info/not-zip-safe new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/httpretty.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/httpretty.egg-info/top_level.txt b/httpretty.egg-info/top_level.txt new file mode 100644 index 0000000..b0284b1 --- /dev/null +++ b/httpretty.egg-info/top_level.txt @@ -0,0 +1 @@ +httpretty diff --git a/httpretty/__init__.py b/httpretty/__init__.py new file mode 100644 index 0000000..5e7d01f --- /dev/null +++ b/httpretty/__init__.py @@ -0,0 +1,94 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# flake8: noqa + + +from . import core + +from .core import httpretty, httprettified, EmptyRequestHeaders +from .core import set_default_thread_timeout, get_default_thread_timeout +from .errors import HTTPrettyError, UnmockedError +from .version import version + +__version__ = version + +# aliases +EmptyRequestHeaders = core.EmptyRequestHeaders +Entry = core.Entry +HTTPrettyRequestEmpty = core.HTTPrettyRequestEmpty +URIInfo = core.URIInfo +URIMatcher = core.URIMatcher +httprettified = core.httprettified +httprettized = core.httprettized +httpretty = core.httpretty + +HTTPretty = httpretty +activate = httprettified + +enabled = httprettized + +enable = httpretty.enable +register_uri = httpretty.register_uri +disable = httpretty.disable +is_enabled = httpretty.is_enabled +reset = httpretty.reset +Response = httpretty.Response + +GET = httpretty.GET +"""Match requests of GET method""" +PUT = httpretty.PUT +"""Match requests of PUT method""" +POST = httpretty.POST +"""Match requests of POST method""" +DELETE = httpretty.DELETE +"""Match requests of DELETE method""" +HEAD = httpretty.HEAD +"""Match requests of HEAD method""" +PATCH = httpretty.PATCH +"""Match requests of OPTIONS method""" +OPTIONS = httpretty.OPTIONS +"""Match requests of OPTIONS method""" +CONNECT = httpretty.CONNECT +"""Match requests of CONNECT method""" + + +def last_request(): + """ + :returns: the last :py:class:`~httpretty.core.HTTPrettyRequest` + """ + return httpretty.last_request + + +def latest_requests(): + """returns the history of made requests""" + return httpretty.latest_requests + + +def has_request(): + """ + :returns: bool - whether any request has been made + """ + return not isinstance(httpretty.last_request.headers, EmptyRequestHeaders) diff --git a/httpretty/compat.py b/httpretty/compat.py new file mode 100644 index 0000000..a8f551c --- /dev/null +++ b/httpretty/compat.py @@ -0,0 +1,60 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import io +import types +from urllib.parse import urlsplit +from urllib.parse import urlunsplit +from urllib.parse import parse_qs +from urllib.parse import quote +from urllib.parse import quote_plus +from urllib.parse import unquote +from urllib.parse import urlencode +from http.server import BaseHTTPRequestHandler + +unquote_utf8 = unquote + + +def encode_obj(in_obj): + return in_obj + + +class BaseClass(object): + def __repr__(self): + return self.__str__() + + +__all__ = [ + 'BaseClass', + 'BaseHTTPRequestHandler', + 'quote', + 'quote_plus', + 'urlencode', + 'urlunsplit', + 'urlsplit', + 'parse_qs', +] diff --git a/httpretty/core.py b/httpretty/core.py new file mode 100644 index 0000000..19715e0 --- /dev/null +++ b/httpretty/core.py @@ -0,0 +1,2082 @@ +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import io +import time +import codecs +import contextlib +import functools +import hashlib +import inspect +import logging +import itertools +import json +import types +import re +import socket +import tempfile +import threading +import traceback +import warnings + +from functools import partial +from typing import Callable + +from .compat import ( + BaseClass, + BaseHTTPRequestHandler, + quote, + quote_plus, + urlencode, + encode_obj, + urlunsplit, + urlsplit, + parse_qs, + unquote_utf8, +) +from .http import ( + STATUSES, + HttpBaseClass, + parse_requestline, + last_requestline, +) + +from .utils import ( + utf8, + decode_utf8, +) + +from .errors import HTTPrettyError, UnmockedError + +from datetime import datetime +from datetime import timedelta +from errno import EAGAIN + +class __internals__: + thread_timeout = 0.1 # https://github.com/gabrielfalcao/HTTPretty/issues/430 + temp_files = [] + threads = [] + + @classmethod + def cleanup_sockets(cls): + cls.cleanup_temp_files() + cls.cleanup_threads() + + @classmethod + def cleanup_threads(cls): + for t in cls.threads: + t.join(cls.thread_timeout) + if t.is_alive(): + raise socket.timeout(cls.thread_timeout) + + @classmethod + def create_thread(cls, *args, **kwargs): + return threading.Thread(*args, **kwargs) + + @classmethod + def cleanup_temp_files(cls): + for fd in cls.temp_files[:]: + try: + fd.close() + except Exception as e: + logger.debug('error closing file {}: {}'.format(fd, e)) + cls.temp_files.remove(fd) + + @classmethod + def create_temp_file(cls): + fd = tempfile.TemporaryFile() + cls.temp_files.append(fd) + return fd + +def set_default_thread_timeout(timeout): + """sets the default thread timeout for HTTPretty threads + + :param timeout: int + """ + __internals__.thread_timeout = timeout + +def get_default_thread_timeout(): + """sets the default thread timeout for HTTPretty threads + + :returns: int + """ + + return __internals__.thread_timeout + + +SOCKET_GLOBAL_DEFAULT_TIMEOUT = socket._GLOBAL_DEFAULT_TIMEOUT +old_socket = socket.socket +old_socketpair = getattr(socket, 'socketpair', None) +old_SocketType = socket.SocketType +old_create_connection = socket.create_connection +old_gethostbyname = socket.gethostbyname +old_gethostname = socket.gethostname +old_getaddrinfo = socket.getaddrinfo +old_socksocket = None +old_ssl_wrap_socket = None +old_sslwrap_simple = None +old_sslsocket = None +old_sslcontext_wrap_socket = None +old_sslcontext = None + +MULTILINE_ANY_REGEX = re.compile(r'.*', re.M) +hostname_re = re.compile(r'\^?(?:https?://)?[^:/]*[:/]?') + + +logger = logging.getLogger(__name__) + +try: # pragma: no cover + import socks + old_socksocket = socks.socksocket +except ImportError: + socks = None + +try: # pragma: no cover + import ssl + old_sslcontext_class = ssl.SSLContext + old_sslcontext = ssl.create_default_context() + old_ssl_wrap_socket = old_sslcontext.wrap_socket + try: + old_sslcontext_wrap_socket = ssl.SSLContext.wrap_socket + except AttributeError: + pass + old_sslsocket = ssl.SSLSocket +except ImportError: # pragma: no cover + ssl = None + +try: + import _ssl +except ImportError: + _ssl = None +# used to handle error caused by ndg-httpsclient +pyopenssl_overrides_inject = [] +pyopenssl_overrides_extract = [] +try: + from requests.packages.urllib3.contrib.pyopenssl import inject_into_urllib3, extract_from_urllib3 + pyopenssl_overrides_extract.append(extract_from_urllib) + pyopenssl_overrides_inject.append(inject_from_urllib) +except Exception: + pass + + + +try: + from urllib3.contrib.pyopenssl import extract_from_urllib3, inject_into_urllib3 + pyopenssl_overrides_extract.append(extract_from_urllib) + pyopenssl_overrides_inject.append(inject_from_urllib) +except Exception: + pass + + +try: + import requests.packages.urllib3.connection as requests_urllib3_connection + old_requests_ssl_wrap_socket = requests_urllib3_connection.ssl_wrap_socket +except ImportError: + requests_urllib3_connection = None + old_requests_ssl_wrap_socket = None + +try: + import eventlet + import eventlet.green +except ImportError: + eventlet = None + +DEFAULT_HTTP_PORTS = frozenset([80]) +POTENTIAL_HTTP_PORTS = set(DEFAULT_HTTP_PORTS) +DEFAULT_HTTPS_PORTS = frozenset([443]) +POTENTIAL_HTTPS_PORTS = set(DEFAULT_HTTPS_PORTS) + + + +def FALLBACK_FUNCTION(x): + return x + + +class HTTPrettyRequest(BaseHTTPRequestHandler, BaseClass): + r"""Represents a HTTP request. It takes a valid multi-line, + ``\r\n`` separated string with HTTP headers and parse them out using + the internal `parse_request` method. + + It also replaces the `rfile` and `wfile` attributes with :py:class:`io.BytesIO` + instances so that we guarantee that it won't make any I/O, neither + for writing nor reading. + + It has some convenience attributes: + + ``headers`` -> a mimetype object that can be cast into a dictionary, + contains all the request headers + + ``protocol`` -> the protocol of this host, inferred from the port + of the underlying fake TCP socket. + + ``host`` -> the hostname of this request. + + ``url`` -> the full url of this request. + + ``path`` -> the path of the request. + + ``method`` -> the HTTP method used in this request. + + ``querystring`` -> a dictionary containing lists with the + attributes. Please notice that if you need a single value from a + query string you will need to get it manually like: + + ``body`` -> the request body as a string. + + ``parsed_body`` -> the request body parsed by ``parse_request_body``. + + .. testcode:: + + >>> request.querystring + {'name': ['Gabriel Falcao']} + >>> print request.querystring['name'][0] + + """ + def __init__(self, headers, body='', sock=None, path_encoding = 'iso-8859-1'): + # first of all, lets make sure that if headers or body are + # unicode strings, it must be converted into a utf-8 encoded + # byte string + self.created_at = time.time() + self.raw_headers = utf8(headers.strip()) + self._body = utf8(body) + self.connection = sock + # Now let's concatenate the headers with the body, and create + # `rfile` based on it + self.rfile = io.BytesIO(b'\r\n\r\n'.join([self.raw_headers, self.body])) + + # Creating `wfile` as an empty BytesIO, just to avoid any + # real I/O calls + self.wfile = io.BytesIO() + + # parsing the request line preemptively + self.raw_requestline = self.rfile.readline() + + # initiating the error attributes with None + self.error_code = None + self.error_message = None + + # Parse the request based on the attributes above + if not self.parse_request(): + return + + # Now 2 convenient attributes for the HTTPretty API: + + # - `path` + # - `querystring` holds a dictionary with the parsed query string + # - `parsed_body` a string + try: + self.path = self.path.encode(path_encoding) + except UnicodeDecodeError: + pass + + self.path = decode_utf8(self.path) + + qstring = self.path.split("?", 1)[-1] + self.querystring = self.parse_querystring(qstring) + + # And the body will be attempted to be parsed as + # `application/json` or `application/x-www-form-urlencoded` + """a dictionary containing parsed request body or None if + HTTPrettyRequest doesn't know how to parse it. It currently + supports parsing body data that was sent under the + ``content`-type` headers values: ``application/json`` or + ``application/x-www-form-urlencoded`` + """ + self.parsed_body = self.parse_request_body(self._body) + + @property + def method(self): + """the HTTP method used in this request""" + return self.command + + @property + def protocol(self): + """the protocol used in this request""" + proto = '' + if not self.connection: + return '' + elif self.connection.is_http: + proto = 'http' + + if self.connection.is_secure: + proto = 'https' + + return proto + + @property + def body(self): + return self._body + + @body.setter + def body(self, value): + self._body = utf8(value) + + # And the body will be attempted to be parsed as + # `application/json` or `application/x-www-form-urlencoded` + self.parsed_body = self.parse_request_body(self._body) + + def __nonzero__(self): + return bool(self.body) or bool(self.raw_headers) + + @property + def url(self): + """the full url of this recorded request""" + return "{}://{}{}".format(self.protocol, self.host, self.path) + + @property + def host(self): + return self.headers.get('Host') or '' + + def __str__(self): + tmpl = '' + return tmpl.format( + self.method, + self.url, + dict(self.headers), + len(self.body), + ) + + def parse_querystring(self, qs): + """parses an UTF-8 encoded query string into a dict of string lists + + :param qs: a querystring + :returns: a dict of lists + + """ + expanded = unquote_utf8(qs) + parsed = parse_qs(expanded) + result = {} + for k in parsed: + result[k] = list(map(decode_utf8, parsed[k])) + + return result + + def parse_request_body(self, body): + """Attempt to parse the post based on the content-type passed. + Return the regular body if not + + :param body: string + :returns: a python object such as dict or list in case the deserialization suceeded. Else returns the given param ``body`` + """ + + PARSING_FUNCTIONS = { + 'application/json': json.loads, + 'text/json': json.loads, + 'application/x-www-form-urlencoded': self.parse_querystring, + } + + content_type = self.headers.get('content-type', '') + + do_parse = PARSING_FUNCTIONS.get(content_type, FALLBACK_FUNCTION) + try: + body = decode_utf8(body) + return do_parse(body) + except Exception: + return body + + +class EmptyRequestHeaders(dict): + """A dict subclass used as internal representation of empty request + headers + """ + + +class HTTPrettyRequestEmpty(object): + """Represents an empty :py:class:`~httpretty.core.HTTPrettyRequest` + where all its properties are somehow empty or ``None`` + """ + + method = None + url = None + body = '' + headers = EmptyRequestHeaders() + + + +class FakeSockFile(object): + """Fake socket file descriptor. Under the hood all data is written in + a temporary file, giving it a real file descriptor number. + + """ + def __init__(self): + self.file = None + self._fileno = None + self.__closed__ = None + self.reset() + + def reset(self): + if self.file: + try: + self.file.close() + except Exception as e: + logger.debug('error closing file {}: {}'.format(self.file, e)) + self.file = None + + self.file = __internals__.create_temp_file() + self._fileno = self.file.fileno() + self.__closed__ = False + + def getvalue(self): + if hasattr(self.file, 'getvalue'): + value = self.file.getvalue() + else: + value = self.file.read() + self.file.seek(0) + return value + + def close(self): + if self.__closed__: + return + self.__closed__ = True + self.flush() + + def flush(self): + try: + super().flush() + except Exception as e: + logger.debug('error closing file {}: {}'.format(self, e)) + + try: + self.file.flush() + except Exception as e: + logger.debug('error closing file {}: {}'.format(self.file, e)) + + + + def fileno(self): + return self._fileno + + def __getattr__(self, name): + try: + return getattr(self.file, name) + except AttributeError: + return super().__getattribute__(name) + + def __del__(self): + try: + self.close() + except (ValueError, AttributeError): + pass + + # Adding the line below as a potential fix of github issue #426 + # that seems to be a compatible the solution of #413 + self.file.close() + + + +class FakeSSLSocket(object): + """Shorthand for :py:class:`~httpretty.core.fakesock` + """ + def __init__(self, sock, *args, **kw): + self._httpretty_sock = sock + + def __getattr__(self, attr): + return getattr(self._httpretty_sock, attr) + + +class FakeAddressTuple(object): + def __init__(self, fakesocket): + self.fakesocket = fakesocket + + def __getitem__(self, *args, **kw): + raise AssertionError('socket {} is not connected'.format(self.fakesocket.truesock)) + + +def fake_socketpair(*args, **kw): + with restored_libs(): + return old_socketpair(*args, **kw) + +class fakesock(object): + """ + fake :py:mod:`socket` + """ + class socket(object): + """drop-in replacement for :py:class:`socket.socket` + """ + _entry = None + _read_buf = None + + debuglevel = 0 + _sent_data = [] + is_secure = False + def __init__( + self, + family=socket.AF_INET, + type=socket.SOCK_STREAM, + proto=0, + fileno=None + ): + self.socket_family = family + self.socket_type = type + self.socket_proto = proto + if httpretty.allow_net_connect: + self.truesock = self.create_socket() + else: + self.truesock = None + + self._address = FakeAddressTuple(self) + self.__truesock_is_connected__ = False + self.fd = FakeSockFile() + self.fd.socket = fileno or self + self.timeout = socket._GLOBAL_DEFAULT_TIMEOUT + self._sock = fileno or self + self.is_http = False + self._bufsize = 32 * 1024 + + def __repr__(self): + return '{self.__class__.__module__}.{self.__class__.__name__}("{self.host}")'.format(**locals()) + + @property + def host(self): + return ":".join(map(str, self._address)) + + def create_socket(self, address=None): + return old_socket(self.socket_family, self.socket_type, self.socket_proto) + + def getpeercert(self, *a, **kw): + now = datetime.now() + shift = now + timedelta(days=30 * 12) + return { + 'notAfter': shift.strftime('%b %d %H:%M:%S GMT'), + 'subjectAltName': ( + ('DNS', '*.%s' % self._host), + ('DNS', self._host), + ('DNS', '*'), + ), + 'subject': ( + ( + ('organizationName', '*.%s' % self._host), + ), + ( + ('organizationalUnitName', + 'Domain Control Validated'), + ), + ( + ('commonName', '*.%s' % self._host), + ), + ), + } + + def ssl(self, sock, *args, **kw): + return sock + + def setsockopt(self, level, optname, value): + if httpretty.allow_net_connect and not self.truesock: + self.truesock = self.create_socket() + elif not self.truesock: + logger.debug('setsockopt(%s, %s, %s) failed', level, optname, value) + return + + return self.truesock.setsockopt(level, optname, value) + + def connect(self, address): + try: + self._address = (self._host, self._port) = address + except ValueError: + # We get here when the address is just a string pointing to a + # unix socket path/file + # + # See issue #206 + self.is_http = False + else: + ports_to_check = ( + POTENTIAL_HTTP_PORTS.union(POTENTIAL_HTTPS_PORTS)) + self.is_http = self._port in ports_to_check + self.is_secure = self._port in POTENTIAL_HTTPS_PORTS + + if not self.is_http: + self.connect_truesock(address=address) + elif self.truesock and not self.real_socket_is_connected(): + # TODO: remove nested if + matcher = httpretty.match_http_address(self._host, self._port) + if matcher is None: + self.connect_truesock(address=address) + + def bind(self, address): + self._address = (self._host, self._port) = address + if self.truesock: + self.bind_truesock(address) + + def bind_truesock(self, address): + if httpretty.allow_net_connect and not self.truesock: + self.truesock = self.create_socket() + elif not self.truesock: + raise UnmockedError('Failed to socket.bind() because because a real socket was never created.', address=address) + + return self.truesock.bind(address) + + def connect_truesock(self, request=None, address=None): + address = address or self._address + + if self.__truesock_is_connected__: + return self.truesock + + if request: + logger.warning('real call to socket.connect() for {request}'.format(**locals())) + elif address: + logger.warning('real call to socket.connect() for {address}'.format(**locals())) + else: + logger.warning('real call to socket.connect()') + + if httpretty.allow_net_connect and not self.truesock: + self.truesock = self.create_socket(address) + elif not self.truesock: + raise UnmockedError('Failed to socket.connect() because because a real socket was never created.', request=request, address=address) + + undo_patch_socket() + try: + hostname = self._address[0] + port = 80 + if len(self._address) == 2: + port = self._address[1] + if port == 443 and old_sslsocket: + self.truesock = old_ssl_wrap_socket(self.truesock, server_hostname=hostname) + + sock = self.truesock + + sock.connect(self._address) + self.__truesock_is_connected__ = True + self.truesock = sock + finally: + apply_patch_socket() + + return self.truesock + + def real_socket_is_connected(self): + return self.__truesock_is_connected__ + + def fileno(self): + if self.truesock: + return self.truesock.fileno() + return self.fd.fileno() + + def close(self): + if self.truesock: + self.truesock.close() + self.truesock = None + self.__truesock_is_connected__ = False + + def makefile(self, mode='r', bufsize=-1): + """Returns this fake socket's own tempfile buffer. + + If there is an entry associated with the socket, the file + descriptor gets filled in with the entry data before being + returned. + """ + self._mode = mode + self._bufsize = bufsize + + if self._entry: + t = __internals__.create_thread( + target=self._entry.fill_filekind, args=(self.fd,) + ) + + # execute body callback and send http response in a + # thread, wait for thread to finish within the timeout + # set via socket.settimeout() + t.start() + if self.timeout == SOCKET_GLOBAL_DEFAULT_TIMEOUT: + timeout = get_default_thread_timeout() + else: + timeout = self.timeout + + # fake socket timeout error by checking if the thread + # finished in time. + t.join(timeout) + if t.is_alive(): + # For more info check issue https://github.com/gabrielfalcao/HTTPretty/issues/430 + raise socket.timeout(timeout) + + return self.fd + + def real_sendall(self, data, *args, **kw): + """Sends data to the remote server. This method is called + when HTTPretty identifies that someone is trying to send + non-http data. + + The received bytes are written in this socket's tempfile + buffer so that HTTPretty can return it accordingly when + necessary. + """ + request = kw.pop('request', None) + if request: + bytecount = len(data) + logger.warning('{self}.real_sendall({bytecount} bytes) to {request.url} via {request.method} at {request.created_at}'.format(**locals())) + + if httpretty.allow_net_connect and not self.truesock: + + self.connect_truesock(request=request) + elif not self.truesock: + raise UnmockedError(request=request) + + if not self.is_http: + self.truesock.setblocking(1) + return self.truesock.sendall(data, *args, **kw) + + sock = self.connect_truesock(request=request) + + sock.setblocking(1) + sock.sendall(data, *args, **kw) + + should_continue = True + while should_continue: + try: + received = sock.recv(self._bufsize) + self.fd.write(received) + should_continue = bool(received.strip()) + + except socket.error as e: + if e.errno == EAGAIN: + continue + break + + self.fd.seek(0) + + def sendall(self, data, *args, **kw): + # if self.__truesock_is_connected__: + # return self.truesock.sendall(data, *args, **kw) + + self._sent_data.append(data) + self.fd = FakeSockFile() + self.fd.socket = self + if isinstance(data, str): + data = data.encode('utf-8') + elif not isinstance(data, bytes): + logger.debug('cannot sendall({data!r})') + data = bytes(data) + + try: + requestline, _ = data.split(b'\r\n', 1) + method, path, version = parse_requestline( + decode_utf8(requestline)) + is_parsing_headers = True + except ValueError: + path = '' + is_parsing_headers = False + + if self._entry is None: + # If the previous request wasn't mocked, don't + # mock the subsequent sending of data + return self.real_sendall(data, *args, **kw) + else: + method = self._entry.method + path = self._entry.info.path + + self.fd.seek(0) + + if not is_parsing_headers: + if len(self._sent_data) > 1: + headers = utf8(last_requestline(self._sent_data)) + meta = self._entry.request.headers + body = utf8(self._sent_data[-1]) + if meta.get('transfer-encoding', '') == 'chunked': + if not body.isdigit() and (body != b'\r\n') and (body != b'0\r\n\r\n'): + self._entry.request.body += body + else: + self._entry.request.body += body + + httpretty.historify_request(headers, body, sock=self) + return + + if path[:2] == '//': + path = '//' + path + # path might come with + s = urlsplit(path) + POTENTIAL_HTTP_PORTS.add(int(s.port or 80)) + parts = list(map(utf8, data.split(b'\r\n\r\n', 1))) + if len(parts) == 2: + headers, body = parts + else: + headers = '' + body = data + + request = httpretty.historify_request(headers, body, sock=self) + + info = URIInfo( + hostname=self._host, + port=self._port, + path=s.path, + query=s.query, + last_request=request + ) + + matcher, entries = httpretty.match_uriinfo(info) + + if not entries: + logger.debug('no entries matching {}'.format(request)) + self._entry = None + self._read_buf = None + self.real_sendall(data, request=request) + return + + self._entry = matcher.get_next_entry(method, info, request) + + def forward_and_trace(self, function_name, *a, **kw): + if not self.truesock: + raise UnmockedError('Failed to socket.{}() because because a real socket was never created.'.format(function_name)) + + callback = getattr(self.truesock, function_name) + return callback(*a, **kw) + + def settimeout(self, new_timeout): + self.timeout = new_timeout + if not self.is_http: + if self.truesock: + self.truesock.settimeout(new_timeout) + + def send(self, data, *args, **kwargs): + self.sendall(data, *args, **kwargs) + return len(data) + + def sendto(self, *args, **kwargs): + return self.forward_and_trace('sendto', *args, **kwargs) + + def recvfrom_into(self, *args, **kwargs): + return self.forward_and_trace('recvfrom_into', *args, **kwargs) + + def recv_into(self, *args, **kwargs): + return self.forward_and_trace('recv_into', *args, **kwargs) + + def recvfrom(self, *args, **kwargs): + return self.forward_and_trace('recvfrom', *args, **kwargs) + + def recv(self, buffersize=0, *args, **kwargs): + if not self._read_buf: + self._read_buf = io.BytesIO() + + if self._entry: + self._entry.fill_filekind(self._read_buf) + + if not self._read_buf: + raise UnmockedError('socket cannot recv(): {!r}'.format(self)) + + return self._read_buf.read(buffersize) + + def __getattr__(self, name): + if name in ('getsockopt', 'selected_alpn_protocol') and not self.truesock: + self.truesock = self.create_socket() + elif httpretty.allow_net_connect and not self.truesock: + # can't call self.connect_truesock() here because we + # don't know if user wants to execute server of client + # calls (or can they?) + self.truesock = self.create_socket() + elif not self.truesock: + # Special case for + # `hasattr(sock, "version")` call added in urllib3>=1.26. + if name == 'version': + raise AttributeError( + "HTTPretty synthesized this error to fix urllib3 compatibility " + "(see issue https://github.com/gabrielfalcao/HTTPretty/issues/409). " + "Please open an issue if this error causes further unexpected issues." + ) + + raise UnmockedError('Failed to socket.{} because because a real socket does not exist'.format(name)) + + return getattr(self.truesock, name) + +def with_socket_is_secure(sock, kw): + sock.is_secure = True + sock.kwargs = kw + for k, v in kw.items(): + setattr(sock, k, v) + return sock + +def fake_wrap_socket(orig_wrap_socket_fn, *args, **kw): + """drop-in replacement for py:func:`ssl.wrap_socket` + """ + if 'sock' in kw: + sock = kw['sock'] + else: + sock = args[0] + + server_hostname = kw.get('server_hostname') + if server_hostname is not None: + matcher = httpretty.match_https_hostname(server_hostname) + if matcher is None: + logger.debug('no requests registered for hostname: "{}"'.format(server_hostname)) + return with_socket_is_secure(sock, kw) + + return with_socket_is_secure(sock, kw) + + +def create_fake_connection( + address, + timeout=socket._GLOBAL_DEFAULT_TIMEOUT, + source_address=None): + """drop-in replacement for :py:func:`socket.create_connection`""" + s = fakesock.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) + if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: + s.settimeout(timeout) + + if isinstance(source_address, tuple) and len(source_address) == 2: + source_address[1] = int(source_address[1]) + + if source_address: + s.bind(source_address) + s.connect(address) + return s + + +def fake_gethostbyname(host): + """drop-in replacement for :py:func:`socket.gethostbyname`""" + return '127.0.0.1' + + +def fake_gethostname(): + """drop-in replacement for :py:func:`socket.gethostname`""" + return 'localhost' + + +def fake_getaddrinfo( + host, port, family=None, socktype=None, proto=None, flags=None): + """drop-in replacement for :py:func:`socket.getaddrinfo`""" + return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, + '', (host, port)), + (socket.AF_INET6, socket.SOCK_STREAM, socket.IPPROTO_TCP, + '', (host, port))] + + +class Entry(BaseClass): + """Created by :py:meth:`~httpretty.core.httpretty.register_uri` and + stored in memory as internal representation of a HTTP + request/response definition. + + Args: + method (str): One of ``httpretty.GET``, ``httpretty.PUT``, ``httpretty.POST``, ``httpretty.DELETE``, ``httpretty.HEAD``, ``httpretty.PATCH``, ``httpretty.OPTIONS``, ``httpretty.CONNECT``. + uri (str|re.Pattern): The URL to match + adding_headers (dict): Extra headers to be added to the response + forcing_headers (dict): Overwrite response headers. + status (int): The status code for the response, defaults to ``200``. + streaming (bool): Whether should stream the response into chunks via generator. + headers: Headers to inject in the faked response. + + Returns: + httpretty.Entry: containing the request-matching metadata. + + + .. warning:: When using the ``forcing_headers`` option make sure to add the header ``Content-Length`` to match at most the total body length, otherwise some HTTP clients can hang indefinitely. + """ + def __init__(self, method, uri, body, + adding_headers=None, + forcing_headers=None, + status=200, + streaming=False, + **headers): + + self.method = method + self.uri = uri + self.info = None + self.request = None + + self.body_is_callable = False + if hasattr(body, "__call__"): + self.callable_body = body + self.body = None + self.body_is_callable = True + elif isinstance(body, str): + self.body = utf8(body) + else: + self.body = body + + self.streaming = streaming + if not streaming and not self.body_is_callable: + self.body_length = len(self.body or '') + else: + self.body_length = 0 + + self.adding_headers = adding_headers or {} + self.forcing_headers = forcing_headers or {} + self.status = int(status) + + for k, v in headers.items(): + name = "-".join(k.split("_")).title() + self.adding_headers[name] = v + + self.validate() + + def validate(self): + """validates the body size with the value of the ``Content-Length`` + header + """ + content_length_keys = 'Content-Length', 'content-length' + for key in content_length_keys: + got = self.adding_headers.get( + key, self.forcing_headers.get(key, None)) + + if got is None: + continue + + igot = None + try: + igot = int(got) + except (ValueError, TypeError): + warnings.warn( + 'HTTPretty got to register the Content-Length header ' + 'with "%r" which is not a number' % got) + return + + if igot and igot > self.body_length: + raise HTTPrettyError( + 'HTTPretty got inconsistent parameters. The header ' + 'Content-Length you registered expects size "%d" but ' + 'the body you registered for that has actually length ' + '"%d".' % ( + igot, self.body_length, + ) + ) + + def __str__(self): + return r''.format( + self.method, + self.uri, + self.status + ) + + def normalize_headers(self, headers): + """Normalize keys in header names so that ``COntent-tyPe`` becomes ``content-type`` + + :param headers: dict + + :returns: dict + """ + new = {} + for k in headers: + new_k = '-'.join([s.lower() for s in k.split('-')]) + new[new_k] = headers[k] + + return new + + def fill_filekind(self, fk): + """writes HTTP Response data to a file descriptor + + :parm fk: a file-like object + + .. warning:: **side-effect:** this method moves the cursor of the given file object to zero + """ + now = datetime.utcnow() + + headers = { + 'status': self.status, + 'date': now.strftime('%a, %d %b %Y %H:%M:%S GMT'), + 'server': 'Python/HTTPretty', + 'connection': 'close', + } + + if self.forcing_headers: + headers = self.forcing_headers + + if self.adding_headers: + headers.update( + self.normalize_headers( + self.adding_headers)) + + headers = self.normalize_headers(headers) + status = headers.get('status', self.status) + if self.body_is_callable: + status, headers, self.body = self.callable_body(self.request, self.info.full_url(), headers) + headers = self.normalize_headers(headers) + # TODO: document this behavior: + if 'content-length' not in headers: + headers.update({ + 'content-length': len(self.body) + }) + + string_list = [ + 'HTTP/1.1 %d %s' % (status, STATUSES[status]), + ] + + if 'date' in headers: + string_list.append('date: %s' % headers.pop('date')) + + if not self.forcing_headers: + content_type = headers.pop('content-type', + 'text/plain; charset=utf-8') + + content_length = headers.pop('content-length', + self.body_length) + + string_list.append('content-type: %s' % content_type) + if not self.streaming: + string_list.append('content-length: %s' % content_length) + + server = headers.pop('server', None) + if server: + string_list.append('server: %s' % server) + + for k, v in headers.items(): + string_list.append( + '{}: {}'.format(k, v), + ) + + for item in string_list: + fk.write(utf8(item) + b'\n') + + fk.write(b'\r\n') + + if self.streaming: + self.body, body = itertools.tee(self.body) + for chunk in body: + fk.write(utf8(chunk)) + else: + fk.write(utf8(self.body)) + + fk.seek(0) + + +def url_fix(s, charset=None): + """escapes special characters + """ + if charset: + warnings.warn("{}.url_fix() charset argument is deprecated".format(__name__), DeprecationWarning) + + scheme, netloc, path, querystring, fragment = urlsplit(s) + path = quote(path, b'/%') + querystring = quote_plus(querystring, b':&=') + return urlunsplit((scheme, netloc, path, querystring, fragment)) + + +class URIInfo(BaseClass): + """Internal representation of `URIs `_ + + .. tip:: all arguments are optional + + :param username: + :param password: + :param hostname: + :param port: + :param path: + :param query: + :param fragment: + :param scheme: + :param last_request: + """ + default_str_attrs = ( + 'username', + 'password', + 'hostname', + 'port', + 'path', + ) + + def __init__(self, + username='', + password='', + hostname='', + port=80, + path='/', + query='', + fragment='', + scheme='', + last_request=None): + + self.username = username or '' + self.password = password or '' + self.hostname = hostname or '' + + if port: + port = int(port) + + elif scheme == 'https': + port = 443 + + self.port = port or 80 + self.path = path or '' + if query: + query_items = sorted(parse_qs(query).items()) + self.query = urlencode( + encode_obj(query_items), + doseq=True, + ) + else: + self.query = '' + if scheme: + self.scheme = scheme + elif self.port in POTENTIAL_HTTPS_PORTS: + self.scheme = 'https' + else: + self.scheme = 'http' + self.fragment = fragment or '' + self.last_request = last_request + + def to_str(self, attrs): + fmt = ", ".join(['%s="%s"' % (k, getattr(self, k, '')) for k in attrs]) + return r'' % fmt + + def __str__(self): + return self.to_str(self.default_str_attrs) + + def str_with_query(self): + attrs = self.default_str_attrs + ('query',) + return self.to_str(attrs) + + def __hash__(self): + return int(hashlib.sha1(bytes(self, 'ascii')).hexdigest(), 16) + + def __eq__(self, other): + self_tuple = ( + self.port, + decode_utf8(self.hostname.lower()), + url_fix(decode_utf8(self.path)), + ) + other_tuple = ( + other.port, + decode_utf8(other.hostname.lower()), + url_fix(decode_utf8(other.path)), + ) + return self_tuple == other_tuple + + def full_url(self, use_querystring=True): + """ + :param use_querystring: bool + :returns: a string with the full url with the format ``{scheme}://{credentials}{domain}{path}{query}`` + """ + credentials = "" + if self.password: + credentials = "{}:{}@".format( + self.username, self.password) + + query = "" + if use_querystring and self.query: + query = "?{}".format(decode_utf8(self.query)) + + result = "{scheme}://{credentials}{domain}{path}{query}".format( + scheme=self.scheme, + credentials=credentials, + domain=self.get_full_domain(), + path=decode_utf8(self.path), + query=query + ) + return result + + def get_full_domain(self): + """ + :returns: a string in the form ``{domain}:{port}`` or just the domain if the port is 80 or 443 + """ + hostname = decode_utf8(self.hostname) + # Port 80/443 should not be appended to the url + if self.port not in DEFAULT_HTTP_PORTS | DEFAULT_HTTPS_PORTS: + return ":".join([hostname, str(self.port)]) + + return hostname + + @classmethod + def from_uri(cls, uri, entry): + """ + :param uri: string + :param entry: an instance of :py:class:`~httpretty.core.Entry` + """ + result = urlsplit(uri) + if result.scheme == 'https': + POTENTIAL_HTTPS_PORTS.add(int(result.port or 443)) + else: + POTENTIAL_HTTP_PORTS.add(int(result.port or 80)) + return cls(result.username, + result.password, + result.hostname, + result.port, + result.path, + result.query, + result.fragment, + result.scheme, + entry) + + +class URIMatcher(object): + regex = None + info = None + + def __init__(self, uri, entries, match_querystring=False, priority=0): + self._match_querystring = match_querystring + # CPython, Jython + regex_types = ('SRE_Pattern', 'org.python.modules.sre.PatternObject', + 'Pattern') + is_regex = type(uri).__name__ in regex_types + if is_regex: + self.regex = uri + result = urlsplit(uri.pattern) + if result.scheme == 'https': + POTENTIAL_HTTPS_PORTS.add(int(result.port or 443)) + else: + POTENTIAL_HTTP_PORTS.add(int(result.port or 80)) + else: + self.info = URIInfo.from_uri(uri, entries) + + self.entries = entries + self.priority = priority + self.uri = uri + # hash of current_entry pointers, per method. + self.current_entries = {} + + def matches(self, info): + if self.info: + # Query string is not considered when comparing info objects, compare separately + return self.info == info and (not self._match_querystring or self.info.query == info.query) + else: + return self.regex.search(info.full_url( + use_querystring=self._match_querystring)) + + def __str__(self): + wrap = 'URLMatcher({})' + if self.info: + if self._match_querystring: + return wrap.format(str(self.info.str_with_query())) + else: + return wrap.format(str(self.info)) + else: + return wrap.format(self.regex.pattern) + + def get_next_entry(self, method, info, request): + """Cycle through available responses, but only once. + Any subsequent requests will receive the last response""" + + if method not in self.current_entries: + self.current_entries[method] = 0 + + # restrict selection to entries that match the requested + # method + entries_for_method = [e for e in self.entries if e.method == method] + + if self.current_entries[method] >= len(entries_for_method): + self.current_entries[method] = -1 + + if not self.entries or not entries_for_method: + raise ValueError('I have no entries for method %s: %s' + % (method, self)) + + entry = entries_for_method[self.current_entries[method]] + if self.current_entries[method] != -1: + self.current_entries[method] += 1 + + # Create a copy of the original entry to make it thread-safe + body = entry.callable_body if entry.body_is_callable else entry.body + new_entry = Entry(entry.method, entry.uri, body, + status=entry.status, + streaming=entry.streaming, + adding_headers=entry.adding_headers, + forcing_headers=entry.forcing_headers) + + # Attach more info to the entry + # So the callback can be more clever about what to do + # This does also fix the case where the callback + # would be handed a compiled regex as uri instead of the + # real uri + new_entry.info = info + new_entry.request = request + return new_entry + + def __hash__(self): + return hash(str(self)) + + def __eq__(self, other): + return str(self) == str(other) + + +class httpretty(HttpBaseClass): + """manages HTTPretty's internal request/response registry and request matching. + """ + _entries = {} + latest_requests = [] + + last_request = HTTPrettyRequestEmpty() + _is_enabled = False + allow_net_connect = True + + @classmethod + def match_uriinfo(cls, info): + """ + :param info: an :py:class:`~httpretty.core.URIInfo` + :returns: a 2-item tuple: (:py:class:`~httpretty.core.URLMatcher`, :py:class:`~httpretty.core.URIInfo`) or ``(None, [])`` + """ + items = sorted( + cls._entries.items(), + key=lambda matcher_entries: matcher_entries[0].priority, + reverse=True, + ) + for matcher, value in items: + if matcher.matches(info): + return (matcher, info) + + return (None, []) + + @classmethod + def match_https_hostname(cls, hostname): + """ + :param hostname: a string + :returns: an :py:class:`~httpretty.core.URLMatcher` or ``None`` + """ + items = sorted( + cls._entries.items(), + key=lambda matcher_entries: matcher_entries[0].priority, + reverse=True, + ) + for matcher, value in items: + if matcher.info is None: + pattern_with_port = "https://{0}:".format(hostname) + pattern_without_port = "https://{0}/".format(hostname) + hostname_pattern = ( + hostname_re + .match(matcher.regex.pattern) + .group(0) + ) + for pattern in [pattern_with_port, pattern_without_port]: + if re.match(hostname_pattern, pattern): + return matcher + + elif matcher.info.hostname == hostname: + return matcher + return None + + @classmethod + def match_http_address(cls, hostname, port): + """ + :param hostname: a string + :param port: an integer + :returns: an :py:class:`~httpretty.core.URLMatcher` or ``None`` + """ + items = sorted( + cls._entries.items(), + key=lambda matcher_entries: matcher_entries[0].priority, + reverse=True, + ) + for matcher, value in items: + if matcher.info is None: + if port in POTENTIAL_HTTPS_PORTS: + scheme = 'https://' + else: + scheme = 'http://' + + pattern_without_port = "{0}{1}/".format(scheme, hostname) + pattern_with_port = "{0}{1}:{2}/".format(scheme, hostname, port) + hostname_pattern = ( + hostname_re + .match(matcher.regex.pattern) + .group(0) + ) + for pattern in [pattern_with_port, pattern_without_port]: + if re.match(hostname_pattern, pattern): + return matcher + + elif matcher.info.hostname == hostname \ + and matcher.info.port == port: + return matcher + + return None + + @classmethod + @contextlib.contextmanager + def record(cls, filename, indentation=4, encoding='utf-8', verbose=False, allow_net_connect=True, pool_manager_params=None): + """ + .. testcode:: + + import io + import json + import requests + import httpretty + + with httpretty.record('/tmp/ip.json'): + data = requests.get('https://httpbin.org/ip').json() + + with io.open('/tmp/ip.json') as fd: + assert data == json.load(fd) + + :param filename: a string + :param indentation: an integer, defaults to **4** + :param encoding: a string, defaults to **"utf-8"** + + :returns: a `context-manager `_ + """ + try: + import urllib3 + except ImportError: + msg = ( + 'HTTPretty requires urllib3 installed ' + 'for recording actual requests.' + ) + raise RuntimeError(msg) + + http = urllib3.PoolManager(**pool_manager_params or {}) + + cls.enable(allow_net_connect, verbose=verbose) + calls = [] + + def record_request(request, uri, headers): + cls.disable() + + kw = {} + kw.setdefault('body', request.body) + kw.setdefault('headers', dict(request.headers)) + response = http.request(request.method, uri, **kw) + calls.append({ + 'request': { + 'uri': uri, + 'method': request.method, + 'headers': dict(request.headers), + 'body': decode_utf8(request.body), + 'querystring': request.querystring + }, + 'response': { + 'status': response.status, + 'body': decode_utf8(response.data), + # urllib3 1.10 had a bug if you just did: + # dict(response.headers) + # which would cause all the values to become lists + # with the header name as the first item and the + # true value as the second item. Workaround that + 'headers': dict(response.headers.items()) + } + }) + cls.enable(allow_net_connect, verbose=verbose) + return response.status, response.headers, response.data + + for method in cls.METHODS: + cls.register_uri(method, MULTILINE_ANY_REGEX, body=record_request) + + yield + cls.disable() + with codecs.open(filename, 'w', encoding) as f: + f.write(json.dumps(calls, indent=indentation)) + + @classmethod + @contextlib.contextmanager + def playback(cls, filename, allow_net_connect=True, verbose=False): + """ + .. testcode:: + + import io + import json + import requests + import httpretty + + with httpretty.record('/tmp/ip.json'): + data = requests.get('https://httpbin.org/ip').json() + + with io.open('/tmp/ip.json') as fd: + assert data == json.load(fd) + + :param filename: a string + :returns: a `context-manager `_ + """ + cls.enable(allow_net_connect, verbose=verbose) + + data = json.loads(open(filename).read()) + for item in data: + uri = item['request']['uri'] + method = item['request']['method'] + body = item['response']['body'] + headers = item['response']['headers'] + cls.register_uri(method, uri, body=body, forcing_headers=headers) + + yield + cls.disable() + + @classmethod + def reset(cls): + """resets the internal state of HTTPretty, unregistering all URLs + """ + POTENTIAL_HTTP_PORTS.intersection_update(DEFAULT_HTTP_PORTS) + POTENTIAL_HTTPS_PORTS.intersection_update(DEFAULT_HTTPS_PORTS) + cls._entries.clear() + cls.latest_requests = [] + cls.last_request = HTTPrettyRequestEmpty() + __internals__.cleanup_sockets() + + @classmethod + def historify_request(cls, headers, body='', sock=None): + """appends request to a list for later retrieval + + .. testcode:: + + import httpretty + + httpretty.register_uri(httpretty.GET, 'https://httpbin.org/ip', body='') + with httpretty.enabled(): + requests.get('https://httpbin.org/ip') + + assert httpretty.latest_requests[-1].url == 'https://httpbin.org/ip' + """ + request = HTTPrettyRequest(headers, body, sock=sock) + cls.last_request = request + + if request not in cls.latest_requests: + cls.latest_requests.append(request) + else: + cls.latest_requests[-1] = request + + logger.info("captured: {}".format(request)) + return request + + @classmethod + def register_uri(cls, method, uri, body='{"message": "HTTPretty :)"}', + adding_headers=None, + forcing_headers=None, + status=200, + responses=None, + match_querystring=False, + priority=0, + **headers): + """ + .. testcode:: + + import httpretty + + + def request_callback(request, uri, response_headers): + content_type = request.headers.get('Content-Type') + assert request.body == '{"nothing": "here"}', 'unexpected body: {}'.format(request.body) + assert content_type == 'application/json', 'expected application/json but received Content-Type: {}'.format(content_type) + return [200, response_headers, json.dumps({"hello": "world"})] + + httpretty.register_uri( + HTTPretty.POST, "https://httpretty.example.com/api", + body=request_callback) + + + with httpretty.enabled(): + requests.post('https://httpretty.example.com/api', data='{"nothing": "here"}', headers={'Content-Type': 'application/json'}) + + assert httpretty.latest_requests[-1].url == 'https://httpbin.org/ip' + + :param method: one of ``httpretty.GET``, ``httpretty.PUT``, ``httpretty.POST``, ``httpretty.DELETE``, ``httpretty.HEAD``, ``httpretty.PATCH``, ``httpretty.OPTIONS``, ``httpretty.CONNECT`` + :param uri: a string or regex pattern (e.g.: **"https://httpbin.org/ip"**) + :param body: a string, defaults to ``{"message": "HTTPretty :)"}`` + :param adding_headers: dict - headers to be added to the response + :param forcing_headers: dict - headers to be forcefully set in the response + :param status: an integer, defaults to **200** + :param responses: a list of entries, ideally each created with :py:meth:`~httpretty.core.httpretty.Response` + :param priority: an integer, useful for setting higher priority over previously registered urls. defaults to zero + :param match_querystring: bool - whether to take the querystring into account when matching an URL + :param headers: headers to be added to the response + + .. warning:: When using a port in the request, add a trailing slash if no path is provided otherwise Httpretty will not catch the request. Ex: ``httpretty.register_uri(httpretty.GET, 'http://fakeuri.com:8080/', body='{"hello":"world"}')`` + """ + uri_is_string = isinstance(uri, str) + + if uri_is_string and re.search(r'^\w+://[^/]+[.]\w{2,}(:[0-9]+)?$', uri): + uri += '/' + + if isinstance(responses, list) and len(responses) > 0: + for response in responses: + response.uri = uri + response.method = method + entries_for_this_uri = responses + else: + headers['body'] = body + headers['adding_headers'] = adding_headers + headers['forcing_headers'] = forcing_headers + headers['status'] = status + + entries_for_this_uri = [ + cls.Response(method=method, uri=uri, **headers), + ] + + matcher = URIMatcher(uri, entries_for_this_uri, + match_querystring, priority) + if matcher in cls._entries: + matcher.entries.extend(cls._entries[matcher]) + del cls._entries[matcher] + + cls._entries[matcher] = entries_for_this_uri + + def __str__(self): + return '' % len(self._entries) + + @classmethod + def Response( + cls, body, + method=None, + uri=None, + adding_headers=None, + forcing_headers=None, + status=200, + streaming=False, + **kw): + """Shortcut to create an :py:class:`~httpretty.core.Entry` that takes + the body as first positional argument. + + .. seealso:: the parameters of this function match those of + the :py:class:`~httpretty.core.Entry` constructor. + + Args: + body (str): The body to return as response.. + method (str): One of ``httpretty.GET``, ``httpretty.PUT``, ``httpretty.POST``, ``httpretty.DELETE``, ``httpretty.HEAD``, ``httpretty.PATCH``, ``httpretty.OPTIONS``, ``httpretty.CONNECT``. + uri (str|re.Pattern): The URL to match + adding_headers (dict): Extra headers to be added to the response + forcing_headers (dict): Overwrite **any** response headers, even "Content-Length". + status (int): The status code for the response, defaults to ``200``. + streaming (bool): Whether should stream the response into chunks via generator. + kwargs: Keyword-arguments are forwarded to :py:class:`~httpretty.core.Entry` + + Returns: + httpretty.Entry: containing the request-matching metadata. + """ + kw['body'] = body + kw['adding_headers'] = adding_headers + kw['forcing_headers'] = forcing_headers + kw['status'] = int(status) + kw['streaming'] = streaming + return Entry(method, uri, **kw) + + @classmethod + def disable(cls): + """Disables HTTPretty entirely, putting the original :py:mod:`socket` + module back in its place. + + + .. code:: + + import re, json + import httpretty + + httpretty.enable() + # request passes through fake socket + response = requests.get('https://httpbin.org') + + httpretty.disable() + # request uses real python socket module + response = requests.get('https://httpbin.org') + + .. note:: This method does not call :py:meth:`httpretty.core.reset` automatically. + """ + undo_patch_socket() + cls._is_enabled = False + + + @classmethod + def is_enabled(cls): + """Check if HTTPretty is enabled + + :returns: bool + + .. testcode:: + + import httpretty + + httpretty.enable() + assert httpretty.is_enabled() == True + + httpretty.disable() + assert httpretty.is_enabled() == False + """ + return cls._is_enabled + + @classmethod + def enable(cls, allow_net_connect=True, verbose=False): + """Enables HTTPretty. + + :param allow_net_connect: boolean to determine if unmatched requests are forwarded to a real network connection OR throw :py:class:`httpretty.errors.UnmockedError`. + :param verbose: boolean to set HTTPretty's logging level to DEBUG + + .. testcode:: + + import re, json + import httpretty + + httpretty.enable(allow_net_connect=True, verbose=True) + + httpretty.register_uri( + httpretty.GET, + re.compile(r'http://.*'), + body=json.dumps({'man': 'in', 'the': 'middle'}) + ) + + response = requests.get('https://foo.bar/foo/bar') + + response.json().should.equal({ + "man": "in", + "the": "middle", + }) + + .. warning:: after calling this method the original :py:mod:`socket` is replaced with :py:class:`httpretty.core.fakesock`. Make sure to call :py:meth:`~httpretty.disable` after done with your tests or use the :py:class:`httpretty.enabled` as decorator or `context-manager `_ + """ + httpretty.allow_net_connect = allow_net_connect + apply_patch_socket() + cls._is_enabled = True + if verbose: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.getLogger().level or logging.WARNING) + + +def apply_patch_socket(): + # Some versions of python internally shadowed the + # SocketType variable incorrectly https://bugs.python.org/issue20386 + bad_socket_shadow = (socket.socket != socket.SocketType) + + new_wrap = None + socket.socket = fakesock.socket + socket.socketpair = fake_socketpair + socket._socketobject = fakesock.socket + if not bad_socket_shadow: + socket.SocketType = fakesock.socket + + socket.create_connection = create_fake_connection + socket.gethostname = fake_gethostname + socket.gethostbyname = fake_gethostbyname + socket.getaddrinfo = fake_getaddrinfo + + socket.__dict__['socket'] = fakesock.socket + socket.__dict__['socketpair'] = fake_socketpair + socket.__dict__['_socketobject'] = fakesock.socket + if not bad_socket_shadow: + socket.__dict__['SocketType'] = fakesock.socket + + socket.__dict__['create_connection'] = create_fake_connection + socket.__dict__['gethostname'] = fake_gethostname + socket.__dict__['gethostbyname'] = fake_gethostbyname + socket.__dict__['getaddrinfo'] = fake_getaddrinfo + + + # Take out the pyopenssl version - use the default implementation + for extract_from_urllib3 in pyopenssl_overrides_extract: + extract_into_urllib3() + + if requests_urllib3_connection is not None: + urllib3_wrap = partial(fake_wrap_socket, old_requests_ssl_wrap_socket) + requests_urllib3_connection.ssl_wrap_socket = urllib3_wrap + requests_urllib3_connection.__dict__['ssl_wrap_socket'] = urllib3_wrap + + if eventlet: + eventlet.green.ssl.GreenSSLContext = old_sslcontext_class + eventlet.green.ssl.__dict__['GreenSSLContext'] = old_sslcontext_class + eventlet.green.ssl.SSLContext = old_sslcontext_class + eventlet.green.ssl.__dict__['SSLContext'] = old_sslcontext_class + + if socks: + socks.socksocket = fakesock.socket + socks.__dict__['socksocket'] = fakesock.socket + + if ssl: + new_wrap = partial(fake_wrap_socket, old_ssl_wrap_socket) + ssl.wrap_socket = new_wrap + ssl.SSLSocket = FakeSSLSocket + ssl.SSLContext = old_sslcontext_class + try: + ssl.SSLContext.wrap_socket = partial(fake_wrap_socket, old_ssl_wrap_socket) + except AttributeError: + pass + + ssl.__dict__['wrap_socket'] = new_wrap + ssl.__dict__['SSLSocket'] = FakeSSLSocket + ssl.__dict__['SSLContext'] = old_sslcontext_class + + +def undo_patch_socket(): + socket.socket = old_socket + socket.socketpair = old_socketpair + socket.SocketType = old_SocketType + socket._socketobject = old_socket + + socket.create_connection = old_create_connection + socket.gethostname = old_gethostname + socket.gethostbyname = old_gethostbyname + socket.getaddrinfo = old_getaddrinfo + + socket.__dict__['socket'] = old_socket + socket.__dict__['socketpair'] = old_socketpair + socket.__dict__['_socketobject'] = old_socket + socket.__dict__['SocketType'] = old_SocketType + + socket.__dict__['create_connection'] = old_create_connection + socket.__dict__['gethostname'] = old_gethostname + socket.__dict__['gethostbyname'] = old_gethostbyname + socket.__dict__['getaddrinfo'] = old_getaddrinfo + + if socks: + socks.socksocket = old_socksocket + socks.__dict__['socksocket'] = old_socksocket + + if ssl: + ssl.wrap_socket = old_ssl_wrap_socket + ssl.SSLSocket = old_sslsocket + try: + ssl.SSLContext.wrap_socket = old_sslcontext_wrap_socket + except AttributeError: + pass + ssl.__dict__['wrap_socket'] = old_ssl_wrap_socket + ssl.__dict__['SSLSocket'] = old_sslsocket + + if requests_urllib3_connection is not None: + requests_urllib3_connection.ssl_wrap_socket = \ + old_requests_ssl_wrap_socket + requests_urllib3_connection.__dict__['ssl_wrap_socket'] = \ + old_requests_ssl_wrap_socket + + + # Put the pyopenssl version back in place + for inject_from_urllib3 in pyopenssl_overrides_inject: + inject_into_urllib3() + + +@contextlib.contextmanager +def restored_libs(): + undo_patch_socket() + yield + apply_patch_socket() + + +class httprettized(object): + """`context-manager `_ for enabling HTTPretty. + + .. tip:: Also available under the alias :py:func:`httpretty.enabled` + + .. testcode:: + + import json + import httpretty + + httpretty.register_uri(httpretty.GET, 'https://httpbin.org/ip', body=json.dumps({'origin': '42.42.42.42'})) + with httpretty.enabled(): + response = requests.get('https://httpbin.org/ip') + + assert httpretty.latest_requests[-1].url == 'https://httpbin.org/ip' + assert response.json() == {'origin': '42.42.42.42'} + """ + def __init__(self, allow_net_connect=True, verbose=False): + self.allow_net_connect = allow_net_connect + self.verbose = verbose + + def __enter__(self): + httpretty.reset() + httpretty.enable(allow_net_connect=self.allow_net_connect, verbose=self.verbose) + + def __exit__(self, exc_type, exc_value, db): + httpretty.disable() + httpretty.reset() + + +def httprettified(test=None, allow_net_connect=True, verbose=False): + """decorator for test functions + + .. tip:: Also available under the alias :py:func:`httpretty.activate` + + :param test: a callable + + + example usage with `nosetests `_ + + .. testcode:: + + import sure + from httpretty import httprettified + + @httprettified + def test_using_nosetests(): + httpretty.register_uri( + httpretty.GET, + 'https://httpbin.org/ip' + ) + + response = requests.get('https://httpbin.org/ip') + + response.json().should.equal({ + "message": "HTTPretty :)" + }) + + example usage with `unittest module `_ + + .. testcode:: + + import unittest + from sure import expect + from httpretty import httprettified + + @httprettified + class TestWithPyUnit(unittest.TestCase): + def test_httpbin(self): + httpretty.register_uri(httpretty.GET, 'https://httpbin.org/ip') + response = requests.get('https://httpbin.org/ip') + expect(response.json()).to.equal({ + "message": "HTTPretty :)" + }) + + """ + def decorate_unittest_TestCase_setUp(klass): + + # Prefer addCleanup (added in python 2.7), but fall back + # to using tearDown if it isn't available + use_addCleanup = hasattr(klass, 'addCleanup') + + original_setUp = (klass.setUp + if hasattr(klass, 'setUp') + else None) + + def new_setUp(self): + httpretty.reset() + httpretty.enable(allow_net_connect, verbose=verbose) + if use_addCleanup: + self.addCleanup(httpretty.disable) + if original_setUp: + original_setUp(self) + klass.setUp = new_setUp + + if not use_addCleanup: + original_tearDown = (klass.setUp + if hasattr(klass, 'tearDown') + else None) + + def new_tearDown(self): + httpretty.disable() + httpretty.reset() + if original_tearDown: + original_tearDown(self) + klass.tearDown = new_tearDown + + return klass + + def decorate_test_methods(klass): + for attr in dir(klass): + if not attr.startswith('test_'): + continue + + attr_value = getattr(klass, attr) + if not hasattr(attr_value, "__call__"): + continue + + setattr(klass, attr, decorate_callable(attr_value)) + return klass + + def is_unittest_TestCase(klass): + try: + import unittest + return issubclass(klass, unittest.TestCase) + except ImportError: + return False + + def decorate_class(klass): + if is_unittest_TestCase(klass): + return decorate_unittest_TestCase_setUp(klass) + return decorate_test_methods(klass) + + def decorate_callable(test): + @functools.wraps(test) + def wrapper(*args, **kw): + with httprettized(allow_net_connect): + return test(*args, **kw) + return wrapper + + if isinstance(test, type): + return decorate_class(test) + elif callable(test): + return decorate_callable(test) + return decorate_callable diff --git a/httpretty/errors.py b/httpretty/errors.py new file mode 100644 index 0000000..149c6c6 --- /dev/null +++ b/httpretty/errors.py @@ -0,0 +1,48 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +from __future__ import unicode_literals +import json + +class HTTPrettyError(Exception): + pass + + +class UnmockedError(HTTPrettyError): + def __init__(self, message='Failed to handle network request', request=None, address=None): + hint = 'Tip: You could try setting (allow_net_connect=True) to allow unregistered requests through a real TCP connection in addition to (verbose=True) to debug the issue.' + if request: + headers = json.dumps(dict(request.headers), indent=2) + message = '{message}.\n\nIntercepted unknown {request.method} request {request.url}\n\nWith headers {headers}'.format(**locals()) + + if isinstance(address, (tuple, list)): + address = ":".join(map(str, address)) + + if address: + hint = 'address: {address} | {hint}'.format(**locals()) + + self.request = request + super(UnmockedError, self).__init__('{message}\n\n{hint}'.format(**locals())) diff --git a/httpretty/http.py b/httpretty/http.py new file mode 100644 index 0000000..87ceca9 --- /dev/null +++ b/httpretty/http.py @@ -0,0 +1,153 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +from __future__ import unicode_literals + +import re +from .compat import BaseClass +from .utils import decode_utf8 + + +STATUSES = { + 100: "Continue", + 101: "Switching Protocols", + 102: "Processing", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 208: "Already Reported", + 226: "IM Used", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 306: "Switch Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request a Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Request Entity Too Large", + 414: "Request-URI Too Long", + 415: "Unsupported Media Type", + 416: "Requested Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 420: "Enhance Your Calm", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Unordered Collection", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 444: "No Response", + 449: "Retry With", + 450: "Blocked by Windows Parental Controls", + 451: "Unavailable For Legal Reasons", + 494: "Request Header Too Large", + 495: "Cert Error", + 496: "No Cert", + 497: "HTTP to HTTPS", + 499: "Client Closed Request", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 509: "Bandwidth Limit Exceeded", + 510: "Not Extended", + 511: "Network Authentication Required", + 598: "Network read timeout error", + 599: "Network connect timeout error", +} + + +class HttpBaseClass(BaseClass): + GET = 'GET' + PUT = 'PUT' + POST = 'POST' + DELETE = 'DELETE' + HEAD = 'HEAD' + PATCH = 'PATCH' + OPTIONS = 'OPTIONS' + CONNECT = 'CONNECT' + METHODS = (GET, PUT, POST, DELETE, HEAD, PATCH, OPTIONS, CONNECT) + + +def parse_requestline(s): + """ + http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5 + + >>> parse_requestline('GET / HTTP/1.0') + ('GET', '/', '1.0') + >>> parse_requestline('post /testurl htTP/1.1') + ('POST', '/testurl', '1.1') + >>> parse_requestline('Im not a RequestLine') + Traceback (most recent call last): + ... + ValueError: Not a Request-Line + """ + methods = '|'.join(HttpBaseClass.METHODS) + m = re.match(r'(' + methods + r')\s+(.*)\s+HTTP/(1.[0|1])', s, re.I) + if m: + return m.group(1).upper(), m.group(2), m.group(3) + else: + raise ValueError('Not a Request-Line') + + +def last_requestline(sent_data): + """ + Find the last line in sent_data that can be parsed with parse_requestline + """ + for line in reversed(sent_data): + try: + parse_requestline(decode_utf8(line)) + except ValueError: + pass + else: + return line diff --git a/httpretty/utils.py b/httpretty/utils.py new file mode 100644 index 0000000..5c6fe20 --- /dev/null +++ b/httpretty/utils.py @@ -0,0 +1,37 @@ +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + + +def utf8(s): + if isinstance(s, str): + s = s.encode('utf-8') + + return bytes(s) + + +def decode_utf8(s): + if isinstance(s, bytes): + s = s.decode("utf-8") + + return str(s) diff --git a/httpretty/version.py b/httpretty/version.py new file mode 100644 index 0000000..67c2416 --- /dev/null +++ b/httpretty/version.py @@ -0,0 +1 @@ +version = '1.1.4' diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2b9b0cb --- /dev/null +++ b/setup.cfg @@ -0,0 +1,18 @@ +[nosetests] +verbosity = 2 +rednose = 1 +with-coverage = 1 +cover-inclusive = 1 +cover-package = httpretty +cover-branches = 1 +nocapture = 1 +nologcapture = 1 +stop = 1 +with-id = 1 +cover-xml = 1 +cover-xml-file = coverage.xml + +[egg_info] +tag_build = +tag_date = 0 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..879decd --- /dev/null +++ b/setup.py @@ -0,0 +1,83 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +import io +import os +from setuptools import setup, find_packages + + +def read_version(): + ctx = {} + exec(local_file('httpretty', 'version.py'), ctx) + return ctx['version'] + + +local_file = lambda *f: \ + io.open( + os.path.join(os.path.dirname(__file__), *f), encoding='utf-8').read() + + +install_requires = [] +tests_requires = ['nose', 'sure', 'coverage', 'mock;python_version<"3.3"', + 'rednose'] + + +setup( + name='httpretty', + version=read_version(), + description='HTTP client mock for Python', + long_description=local_file('README.rst'), + author='Gabriel Falcao', + author_email='gabriel@nacaolivre.org', + url='https://httpretty.readthedocs.io/en/latest/', + zip_safe=False, + packages=find_packages(exclude=['*tests*']), + tests_require=local_file('development.txt').splitlines(), + install_requires=install_requires, + license='MIT', + test_suite='nose.collector', + project_urls={ + "Documentation": "https://httpretty.readthedocs.io/en/latest/", + "Source Code": "https://github.com/gabrielfalcao/httpretty", + "Issue Tracker": "https://github.com/gabrielfalcao/httpretty/issues", + "Continuous Integration": "https://github.com/gabrielfalcao/HTTPretty/actions/workflows/pyenv.yml?query=branch%3Amaster+event%3Apush", + "Test Coverage": "https://codecov.io/gh/gabrielfalcao/httpretty", + }, + python_requires='>=3', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Software Development :: Testing' + ], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..9047465 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +import sure diff --git a/tests/bugfixes/nosetests/__init__.py b/tests/bugfixes/nosetests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bugfixes/nosetests/test_242_ssl_bad_handshake.py b/tests/bugfixes/nosetests/test_242_ssl_bad_handshake.py new file mode 100644 index 0000000..0653b41 --- /dev/null +++ b/tests/bugfixes/nosetests/test_242_ssl_bad_handshake.py @@ -0,0 +1,25 @@ +import httpretty +import requests + + +@httpretty.activate +def test_test_ssl_bad_handshake(): + # Reproduces https://github.com/gabrielfalcao/HTTPretty/issues/242 + + url_http = 'http://httpbin.org/status/200' + url_https = 'https://github.com/gabrielfalcao/HTTPretty' + + httpretty.register_uri(httpretty.GET, url_http, body='insecure') + httpretty.register_uri(httpretty.GET, url_https, body='encrypted') + + requests.get(url_http).text.should.equal('insecure') + requests.get(url_https).text.should.equal('encrypted') + + httpretty.latest_requests().should.have.length_of(2) + insecure_request, secure_request = httpretty.latest_requests()[:2] + + insecure_request.protocol.should.be.equal('http') + secure_request.protocol.should.be.equal('https') + + insecure_request.url.should.be.equal(url_http) + secure_request.url.should.be.equal(url_https) diff --git a/tests/bugfixes/nosetests/test_387_regex_port.py b/tests/bugfixes/nosetests/test_387_regex_port.py new file mode 100644 index 0000000..c3f90cd --- /dev/null +++ b/tests/bugfixes/nosetests/test_387_regex_port.py @@ -0,0 +1,26 @@ +# based on the snippet from https://github.com/gabrielfalcao/HTTPretty/issues/387 + +import httpretty +import requests +from sure import expect + +@httpretty.activate(allow_net_connect=False, verbose=True) +def test_match_with_port_no_slashes(): + "Reproduce #387 registering host:port without trailing slash" + httpretty.register_uri(httpretty.GET, 'http://fakeuri.com:8080', body='{"hello":"world"}') + req = requests.get('http://fakeuri.com:8080', timeout=1) + expect(req.status_code).to.equal(200) + expect(req.json()).to.equal({"hello": "world"}) + + +@httpretty.activate(allow_net_connect=False, verbose=True) +def test_match_with_port_trailing_slash(): + "Reproduce #387 registering host:port with trailing slash" + httpretty.register_uri(httpretty.GET, 'https://fakeuri.com:443/', body='{"hello":"world"}') + req = requests.get('https://fakeuri.com:443', timeout=1) + expect(req.status_code).to.equal(200) + expect(req.json()).to.equal({"hello": "world"}) + + req = requests.get('https://fakeuri.com:443/', timeout=1) + expect(req.status_code).to.equal(200) + expect(req.json()).to.equal({"hello": "world"}) diff --git a/tests/bugfixes/nosetests/test_388_unmocked_error_with_url.py b/tests/bugfixes/nosetests/test_388_unmocked_error_with_url.py new file mode 100644 index 0000000..ff8b756 --- /dev/null +++ b/tests/bugfixes/nosetests/test_388_unmocked_error_with_url.py @@ -0,0 +1,56 @@ +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +import requests +import httpretty +from httpretty.errors import UnmockedError + +from unittest import skip +from sure import expect + + +def http(): + sess = requests.Session() + adapter = requests.adapters.HTTPAdapter(pool_connections=1, pool_maxsize=1) + sess.mount('http://', adapter) + sess.mount('https://', adapter) + return sess + +@httpretty.activate(allow_net_connect=False) +def test_https_forwarding(): + "#388 UnmockedError is raised with details about the mismatched request" + httpretty.register_uri(httpretty.GET, 'http://google.com/', body="Not Google") + httpretty.register_uri(httpretty.GET, 'https://google.com/', body="Not Google") + response1 = http().get('http://google.com/') + response2 = http().get('https://google.com/') + + http().get.when.called_with("https://github.com/gabrielfalcao/HTTPretty").should.have.raised(UnmockedError, 'https://github.com/gabrielfalcao/HTTPretty') + + response1.text.should.equal(response2.text) + try: + http().get("https://github.com/gabrielfalcao/HTTPretty") + except UnmockedError as exc: + expect(exc).to.have.property('request') + expect(exc.request).to.have.property('host').being.equal('github.com') + expect(exc.request).to.have.property('protocol').being.equal('https') + expect(exc.request).to.have.property('url').being.equal('https://github.com/gabrielfalcao/HTTPretty') diff --git a/tests/bugfixes/nosetests/test_413_regex.py b/tests/bugfixes/nosetests/test_413_regex.py new file mode 100644 index 0000000..2131f7f --- /dev/null +++ b/tests/bugfixes/nosetests/test_413_regex.py @@ -0,0 +1,39 @@ +# File based on the snippet provided in https://github.com/gabrielfalcao/HTTPretty/issues/413#issue-787264551 +import requests +import httpretty +import re + + +def mock_body(request, url, response_headers): + return [200, response_headers, "Mocked " + url] + + +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_works_with_regex_path(): + "Issue #413 regex with path" + patmatchpat = re.compile("/file-one") + + httpretty.register_uri(httpretty.GET, patmatchpat, body=mock_body) + + response = requests.get("https://example.com/file-one.html") + response.status_code.should.equal(200) + response.text.should.equal("Mocked https://example.com/file-one.html") + + response = requests.get("https://github.com/file-one.json") + response.status_code.should.equal(200) + response.text.should.equal("Mocked https://github.com/file-one.json") + +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_works_with_regex_dotall(): + "Issue #413 regex with .*" + patmatchpat = re.compile(".*/file-two.*") + + httpretty.register_uri(httpretty.GET, patmatchpat, body=mock_body) + + response = requests.get("https://example.com/file-two.html") + response.status_code.should.equal(200) + response.text.should.equal("Mocked https://example.com/file-two.html") + + response = requests.get("https://github.com/file-two.json") + response.status_code.should.equal(200) + response.text.should.equal("Mocked https://github.com/file-two.json") diff --git a/tests/bugfixes/nosetests/test_414_httpx.py b/tests/bugfixes/nosetests/test_414_httpx.py new file mode 100644 index 0000000..889b871 --- /dev/null +++ b/tests/bugfixes/nosetests/test_414_httpx.py @@ -0,0 +1,13 @@ +import httpretty +import httpx +from sure import expect + +@httpretty.activate(verbose=True, allow_net_connect=False) +def test_httpx(): + "#414 httpx support" + httpretty.register_uri(httpretty.GET, "https://blog.falcao.it/", + body="Posts") + + response = httpx.get('https://blog.falcao.it') + + expect(response.text).to.equal("Posts") diff --git a/tests/bugfixes/nosetests/test_416_boto3.py b/tests/bugfixes/nosetests/test_416_boto3.py new file mode 100644 index 0000000..8db807b --- /dev/null +++ b/tests/bugfixes/nosetests/test_416_boto3.py @@ -0,0 +1,33 @@ +import httpretty +import boto3 +from botocore.exceptions import ClientError + +from sure import expect + + +@httpretty.activate(allow_net_connect=False, verbose=True) +def test_boto3(): + "#416 boto3 issue" + httpretty.register_uri( + httpretty.PUT, + "https://foo-bucket.s3.amazonaws.com/foo-object", + body=""" + + AccessDenied + Access Denied + foo + foo + """, + status=403 + ) + + session = boto3.Session(aws_access_key_id="foo", aws_secret_access_key="foo") + s3_client = session.client('s3') + + put_object = expect(s3_client.put_object).when.called_with( + Bucket="foo-bucket", + Key="foo-object", + Body=b"foo" + ) + + put_object.should.have.raised(ClientError, 'Access Denied') diff --git a/tests/bugfixes/nosetests/test_417_openssl.py b/tests/bugfixes/nosetests/test_417_openssl.py new file mode 100644 index 0000000..750a3fc --- /dev/null +++ b/tests/bugfixes/nosetests/test_417_openssl.py @@ -0,0 +1,33 @@ +# This test is based on @ento's example snippet: +# https://gist.github.com/ento/e1e33d7d67e406bf03fe61f018404c21 + +# Original Issue: +# https://github.com/gabrielfalcao/HTTPretty/issues/417 +import httpretty +import requests +import urllib3 +from sure import expect +from unittest import skipIf +try: + from urllib3.contrib.pyopenssl import extract_from_urllib3 +except Exception: + extract_from_urllib3 = None + + +@skipIf(extract_from_urllib3 is None, + "urllib3.contrib.pyopenssl.extract_from_urllib3 does not exist") +def test_enable_disable_httpretty_extract(): + "#417 urllib3.contrib.pyopenssl enable -> disable extract" + expect(urllib3.util.IS_PYOPENSSL).to.be.false + httpretty.enable() + httpretty.disable() + extract_from_urllib3() + expect(urllib3.util.IS_PYOPENSSL).to.be.false + +def test_enable_disable_httpretty(): + "#417 urllib3.contrib.pyopenssl enable -> disable extract" + expect(urllib3.util.IS_PYOPENSSL).to.be.false + httpretty.enable() + httpretty.disable() + extract_from_urllib3() + expect(urllib3.util.IS_PYOPENSSL).to.be.false diff --git a/tests/bugfixes/nosetests/test_425_latest_requests.py b/tests/bugfixes/nosetests/test_425_latest_requests.py new file mode 100644 index 0000000..72578c8 --- /dev/null +++ b/tests/bugfixes/nosetests/test_425_latest_requests.py @@ -0,0 +1,29 @@ +import requests +import httpretty +from httpretty.errors import UnmockedError + +from unittest import skip +from sure import expect + + +@httpretty.activate(allow_net_connect=True) +def test_latest_requests(): + "#425 - httpretty.latest_requests() can be called multiple times" + httpretty.register_uri(httpretty.GET, 'http://google.com/', body="Not Google") + httpretty.register_uri(httpretty.GET, 'https://google.com/', body="Not Google") + + requests.get('http://google.com/') + httpretty.latest_requests()[-1].url.should.equal('http://google.com/') + requests.get('https://google.com/') + httpretty.latest_requests()[-1].url.should.equal('https://google.com/') + + httpretty.latest_requests().should.have.length_of(2) + httpretty.latest_requests()[-1].url.should.equal('https://google.com/') + + requests.get('https://google.com/') + httpretty.latest_requests().should.have.length_of(3) + httpretty.latest_requests()[-1].url.should.equal('https://google.com/') + + requests.get('http://google.com/') + httpretty.latest_requests().should.have.length_of(4) + httpretty.latest_requests()[-1].url.should.equal('http://google.com/') diff --git a/tests/bugfixes/nosetests/test_430_respect_timeout.py b/tests/bugfixes/nosetests/test_430_respect_timeout.py new file mode 100644 index 0000000..f21a546 --- /dev/null +++ b/tests/bugfixes/nosetests/test_430_respect_timeout.py @@ -0,0 +1,54 @@ +# This test is based on @mariojonke snippet: +# https://github.com/gabrielfalcao/HTTPretty/issues/430 +import time +from requests import Session +from requests.adapters import HTTPAdapter +from requests.exceptions import ReadTimeout + +from threading import Event + +from httpretty import httprettified +from httpretty import HTTPretty + + +def http(max_connections=1): + session = Session() + adapter = HTTPAdapter( + pool_connections=max_connections, + pool_maxsize=max_connections + ) + session.mount('http://', adapter) + session.mount('https://', adapter) + return session + + + +@httprettified(verbose=True, allow_net_connect=False) +def test_read_timeout(): + "#430 httpretty should respect read timeout" + event = Event() + uri = "http://example.com" + + # Given that I register a uri with a callback body that delays 10 seconds + wait_seconds = 10 + + def my_callback(request, url, headers): + event.wait(wait_seconds) + return 200, headers, "Received" + + HTTPretty.register_uri(HTTPretty.GET, uri, body=my_callback) + + # And I use a thread pool with 1 TCP connection max + max_connections = 1 + request = http(max_connections) + started_at = time.time() + # When I make an HTTP request with a read timeout of 0.1 and an indefinite connect timeout + when_called = request.get.when.called_with(uri, timeout=(None, 0.1)) + + # Then the request should have raised a connection timeout + when_called.should.have.raised(ReadTimeout) + + # And the total execution time should be less than 0.2 seconds + event.set() + total_time = time.time() - started_at + total_time.should.be.lower_than(0.2) diff --git a/tests/bugfixes/nosetests/test_eventlet.py b/tests/bugfixes/nosetests/test_eventlet.py new file mode 100644 index 0000000..25a58a9 --- /dev/null +++ b/tests/bugfixes/nosetests/test_eventlet.py @@ -0,0 +1,11 @@ +import httpretty +import requests +import eventlet +eventlet.monkey_patch(all=False, socket=True) + + +@httpretty.activate +def test_something(): + + httpretty.register_uri(httpretty.GET, 'https://example.com', body='foo') + requests.get('https://example.com').text.should.equal('foo') diff --git a/tests/bugfixes/nosetests/test_redis.py b/tests/bugfixes/nosetests/test_redis.py new file mode 100644 index 0000000..e7328a4 --- /dev/null +++ b/tests/bugfixes/nosetests/test_redis.py @@ -0,0 +1,52 @@ +import os +import requests +import httpretty + +try: + from redis import Redis +except ImportError: + Redis = None + +from unittest import skipUnless + + +def redis_available(): + if Redis is None: + return False + + params = dict( + host=os.getenv('REDIS_HOST') or '127.0.0.1', + port=int(os.getenv('REDIS_PORT') or 6379) + ) + conn = Redis(**params) + try: + conn.keys('*') + conn.close() + return True + except Exception: + return False + + +@skipUnless(redis_available(), reason='no redis server available for test') +@httpretty.activate() +def test_work_in_parallel_to_redis(): + "HTTPretty should passthrough redis connections" + + redis = Redis() + + keys = redis.keys('*') + for key in keys: + redis.delete(key) + + redis.append('item1', 'value1') + redis.append('item2', 'value2') + + sorted(redis.keys('*')).should.equal([b'item1', b'item2']) + + httpretty.register_uri( + httpretty.GET, + "http://redis.io", + body="salvatore") + + response = requests.get('http://redis.io') + response.text.should.equal('salvatore') diff --git a/tests/bugfixes/nosetests/test_tornado_bind_unused_port.py b/tests/bugfixes/nosetests/test_tornado_bind_unused_port.py new file mode 100644 index 0000000..82cb2b9 --- /dev/null +++ b/tests/bugfixes/nosetests/test_tornado_bind_unused_port.py @@ -0,0 +1,18 @@ +import httpretty +from unittest import skip +from tornado.testing import bind_unused_port + + +@skip('') +@httpretty.activate(allow_net_connect=True) +def test_passthrough_binding_socket(): + # issue #247 + + result = bind_unused_port() + result.should.be.a(tuple) + result.should.have.length_of(2) + + socket, port = result + + port.should.be.an(int) + socket.close() diff --git a/tests/bugfixes/pytest/test_426_mypy_segfault.py b/tests/bugfixes/pytest/test_426_mypy_segfault.py new file mode 100644 index 0000000..3a60601 --- /dev/null +++ b/tests/bugfixes/pytest/test_426_mypy_segfault.py @@ -0,0 +1,81 @@ +import time +import requests +import json +import unittest +import re +import httpretty + + +class GenerateTests(type): + def __init__(cls, name, bases, attrs): + if name in ('GenerateTestMeta',): return + + count = getattr(cls, '__generate_count__', attrs.get('__generate_count__')) + if not isinstance(count, int): + raise SyntaxError(f'Metaclass requires def `__generate_count__ = NUMBER_OF_TESTS` to be set to an integer') + + generate_method = getattr(cls, '__generate_method__', attrs.get('__generate_method__')) + if not callable(generate_method): + raise SyntaxError(f'Metaclass requires def `__generate_method__(test_name):` to be implemented') + + + for x in range(count): + test_name = "test_{}".format(x) + def test_func(self, *args, **kwargs): + run_test = generate_method(test_name) + run_test(self, *args, **kwargs) + + test_func.__name__ = test_name + attrs[test_name] = test_func + setattr(cls, test_name, test_func) + + +class TestBug426MypySegfaultWithCallbackAndPayload(unittest.TestCase, metaclass=GenerateTests): + __generate_count__ = 1000 + + def __generate_method__(test_name): + @httpretty.httprettified(allow_net_connect=False) + def test_func(self): + httpretty.register_uri(httpretty.GET, 'http://github.com', body=self.json_response_callback({"kind": "insecure"})) + httpretty.register_uri(httpretty.GET, 'https://github.com', body=self.json_response_callback({"kind": "secure"})) + httpretty.register_uri(httpretty.POST, re.compile('github.com/.*'), body=self.json_response_callback({"kind": "regex"}) ) + + response = requests.post( + 'https://github.com/foo', + headers={ + "Content-Type": "application/json" + }, + data=json.dumps({test_name: time.time()})) + + assert response.status_code == 200 + + try: + response = requests.get('https://gitlab.com') + assert response.status_code == 200 + except Exception: + pass + + return test_func + + def json_response_callback(self, data): + + payload = dict(data) + payload.update({ + "time": time.time() + }) + + def request_callback(request, path, headers): + return [200, headers, json.dumps(payload)] + + return request_callback + + +class TestBug426MypySegfaultWithEmptyMethod(unittest.TestCase, metaclass=GenerateTests): + __generate_count__ = 10000 + + def __generate_method__(test_name): + @httpretty.httprettified(allow_net_connect=False) + def test_func(self): + pass + + return test_func diff --git a/tests/compat.py b/tests/compat.py new file mode 100644 index 0000000..8a6c903 --- /dev/null +++ b/tests/compat.py @@ -0,0 +1,4 @@ +try: + from unittest.mock import Mock, patch, call, MagicMock +except ImportError: + from mock import Mock, patch, call, MagicMock diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py new file mode 100644 index 0000000..1b809ba --- /dev/null +++ b/tests/functional/__init__.py @@ -0,0 +1,28 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import warnings +warnings.simplefilter('ignore') diff --git a/tests/functional/base.py b/tests/functional/base.py new file mode 100644 index 0000000..7d2ecee --- /dev/null +++ b/tests/functional/base.py @@ -0,0 +1,118 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +from __future__ import unicode_literals + +import os +import json +import socket +import threading + +import tornado.ioloop +import tornado.web +from functools import wraps + +from os.path import abspath, dirname, join +from httpretty.core import POTENTIAL_HTTP_PORTS, old_socket + + +def get_free_tcp_port(): + """returns a TCP port that can be used for listen in the host. + """ + tcp = old_socket(socket.AF_INET, socket.SOCK_STREAM) + tcp.bind(('', 0)) + host, port = tcp.getsockname() + tcp.close() + return port + + +LOCAL_FILE = lambda *path: join(abspath(dirname(__file__)), *path) +FIXTURE_FILE = lambda name: LOCAL_FILE('fixtures', name) + + +class JSONEchoHandler(tornado.web.RequestHandler): + def get(self, matched): + payload = dict([(x, self.get_argument(x)) for x in self.request.arguments]) + self.write(json.dumps({matched or 'index': payload}, indent=4)) + + def post(self, matched): + payload = dict(self.request.arguments) + self.write(json.dumps({ + matched or 'index': payload, + 'req_body': self.request.body.decode('utf-8'), + 'req_headers': dict(self.request.headers.items()), + }, indent=4)) + + +class JSONEchoServer(threading.Thread): + def __init__(self, lock, port, *args, **kw): + self.lock = lock + self.port = int(port) + self._stop = threading.Event() + super(JSONEchoServer, self).__init__(*args, **kw) + self.daemon = True + + def stop(self): + self._stop.set() + + def stopped(self): + return self._stop.isSet() + + def setup_application(self): + return tornado.web.Application([ + (r"/(.*)", JSONEchoHandler), + ]) + + def run(self): + loop = tornado.ioloop.IOLoop() + + application = self.setup_application() + application.listen(self.port) + self.lock.release() + loop.start() + + +def use_tornado_server(callback): + lock = threading.Lock() + lock.acquire() + + @wraps(callback) + def func(*args, **kw): + port = os.getenv('TEST_PORT', get_free_tcp_port()) + POTENTIAL_HTTP_PORTS.add(port) + kw['port'] = port + server = JSONEchoServer(lock, port) + server.start() + try: + lock.acquire() + callback(*args, **kw) + finally: + lock.release() + server.stop() + if port in POTENTIAL_HTTP_PORTS: + POTENTIAL_HTTP_PORTS.remove(port) + return func diff --git a/tests/functional/fixtures/playback-1.json b/tests/functional/fixtures/playback-1.json new file mode 100644 index 0000000..1eef307 --- /dev/null +++ b/tests/functional/fixtures/playback-1.json @@ -0,0 +1,58 @@ +[ + { + "request": { + "body": "", + "headers": { + "host": "localhost:8888", + "accept-encoding": "gzip, deflate, compress", + "content-length": "0", + "accept": "*/*", + "user-agent": "python-requests/1.1.0 CPython/2.7.5 Darwin/12.5.0" + }, + "querystring": { + "age": [ + "25" + ], + "name": [ + "Gabriel" + ] + }, + "uri": "http://localhost:8888/foobar?name=Gabriel&age=25", + "method": "GET" + }, + "response": { + "status": 200, + "body": "{\n \"foobar\": {\n \"age\": \"25\", \n \"name\": \"Gabriel\"\n }\n}", + "headers": { + "content-length": "73", + "etag": "\"6fdccaba6542114e7d1098d22a01623dc2aa5761\"", + "content-type": "text/html; charset=UTF-8", + "server": "TornadoServer/2.4" + } + } + }, + { + "request": { + "body": "{\"test\": \"123\"}", + "headers": { + "host": "localhost:8888", + "accept-encoding": "gzip, deflate, compress", + "content-length": "15", + "accept": "*/*", + "user-agent": "python-requests/1.1.0 CPython/2.7.5 Darwin/12.5.0" + }, + "querystring": {}, + "uri": "http://localhost:8888/foobar", + "method": "POST" + }, + "response": { + "status": 200, + "body": "{\n \"foobar\": {}\n}", + "headers": { + "content-length": "20", + "content-type": "text/html; charset=UTF-8", + "server": "TornadoServer/2.4" + } + } + } +] \ No newline at end of file diff --git a/tests/functional/test_bypass.py b/tests/functional/test_bypass.py new file mode 100644 index 0000000..e85dfac --- /dev/null +++ b/tests/functional/test_bypass.py @@ -0,0 +1,212 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +from __future__ import unicode_literals +import time +import requests +try: + import urllib.request as urllib2 +except ImportError: + import urllib2 + +from .testserver import TornadoServer, TCPServer, TCPClient +from .base import get_free_tcp_port +from sure import expect, that_with_context + +import functools + +import httpretty +from httpretty import core, HTTPretty + + +def start_http_server(context): + if httpretty.httpretty._is_enabled: + allow_net_connect = httpretty.httpretty.allow_net_connect + else: + allow_net_connect = True + httpretty.disable() + context.http_port = get_free_tcp_port() + context.server = TornadoServer(context.http_port) + context.server.start() + ready = False + timeout = 2 + started_at = time.time() + while not ready: + httpretty.disable() + time.sleep(.1) + try: + requests.get('http://localhost:{}/'.format(context.http_port)) + ready = True + except Exception: + if time.time() - started_at >= timeout: + break + + httpretty.enable(allow_net_connect=allow_net_connect) + + +def stop_http_server(context): + context.server.stop() + httpretty.enable() + + +def start_tcp_server(context): + context.tcp_port = get_free_tcp_port() + context.server = TCPServer(context.tcp_port) + context.server.start() + context.client = TCPClient(context.tcp_port) + httpretty.enable() + + +def stop_tcp_server(context): + context.server.stop() + context.client.close() + httpretty.enable() + + +@httpretty.activate +@that_with_context(start_http_server, stop_http_server) +def test_httpretty_bypasses_when_disabled(context): + "httpretty should bypass all requests by disabling it" + + httpretty.register_uri( + httpretty.GET, "http://localhost:{}/go-for-bubbles/".format(context.http_port), + body="glub glub") + + httpretty.disable() + + fd = urllib2.urlopen('http://localhost:{}/go-for-bubbles/'.format(context.http_port)) + got1 = fd.read() + fd.close() + + expect(got1).to.equal( + b'. o O 0 O o . o O 0 O o . o O 0 O o . o O 0 O o . o O 0 O o .') + + fd = urllib2.urlopen('http://localhost:{}/come-again/'.format(context.http_port)) + got2 = fd.read() + fd.close() + + expect(got2).to.equal(b'<- HELLO WORLD ->') + + httpretty.enable() + + fd = urllib2.urlopen('http://localhost:{}/go-for-bubbles/'.format(context.http_port)) + got3 = fd.read() + fd.close() + + expect(got3).to.equal(b'glub glub') + core.POTENTIAL_HTTP_PORTS.remove(context.http_port) + + +@httpretty.activate(verbose=True) +@that_with_context(start_http_server, stop_http_server) +def test_httpretty_bypasses_a_unregistered_request(context): + "httpretty should bypass a unregistered request by disabling it" + + httpretty.register_uri( + httpretty.GET, "http://localhost:{}/go-for-bubbles/".format(context.http_port), + body="glub glub") + + fd = urllib2.urlopen('http://localhost:{}/go-for-bubbles/'.format(context.http_port)) + got1 = fd.read() + fd.close() + + expect(got1).to.equal(b'glub glub') + + fd = urllib2.urlopen('http://localhost:{}/come-again/'.format(context.http_port)) + got2 = fd.read() + fd.close() + + expect(got2).to.equal(b'<- HELLO WORLD ->') + core.POTENTIAL_HTTP_PORTS.remove(context.http_port) + + +@httpretty.activate(verbose=True) +@that_with_context(start_tcp_server, stop_tcp_server) +def test_using_httpretty_with_other_tcp_protocols(context): + "httpretty should work even when testing code that also use other TCP-based protocols" + + httpretty.register_uri( + httpretty.GET, "http://falcao.it/foo/", + body="BAR") + + fd = urllib2.urlopen('http://falcao.it/foo/') + got1 = fd.read() + fd.close() + + expect(got1).to.equal(b'BAR') + + expect(context.client.send("foobar")).to.equal(b"RECEIVED: foobar") + + +@httpretty.activate(allow_net_connect=False) +@that_with_context(start_http_server, stop_http_server) +def test_disallow_net_connect_1(context, verbose=True): + """ + When allow_net_connect = False, a request that otherwise + would have worked results in UnmockedError. + """ + httpretty.register_uri(httpretty.GET, "http://falcao.it/foo/", + body="BAR") + + def foo(): + fd = None + try: + fd = urllib2.urlopen('http://localhost:{}/go-for-bubbles/'.format(context.http_port)) + finally: + if fd: + fd.close() + + foo.should.throw(httpretty.UnmockedError) + + +@httpretty.activate(allow_net_connect=False) +def test_disallow_net_connect_2(): + """ + When allow_net_connect = False, a request that would have + failed results in UnmockedError. + """ + + def foo(): + fd = None + try: + fd = urllib2.urlopen('http://example.com/nonsense') + finally: + if fd: + fd.close() + + foo.should.throw(httpretty.UnmockedError) + + +@httpretty.activate(allow_net_connect=False) +def test_disallow_net_connect_3(): + "When allow_net_connect = False, mocked requests still work correctly." + + httpretty.register_uri(httpretty.GET, "http://falcao.it/foo/", + body="BAR") + fd = urllib2.urlopen('http://falcao.it/foo/') + got1 = fd.read() + fd.close() + expect(got1).to.equal(b'BAR') diff --git a/tests/functional/test_debug.py b/tests/functional/test_debug.py new file mode 100644 index 0000000..86bf09e --- /dev/null +++ b/tests/functional/test_debug.py @@ -0,0 +1,91 @@ +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +import socket +from unittest import skip +from sure import scenario, expect +from httpretty import httprettified + + +def create_socket(context): + context.sock = socket.socket( + socket.AF_INET, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + ) + context.sock.is_http = True + + +@skip('not currently supported') +@httprettified +@scenario(create_socket) +def test_httpretty_debugs_socket_send(context): + "HTTPretty should forward_and_trace socket.send" + + expect(context.sock.send).when.called_with(b'data').to.throw( + "not connected" + ) + + +@skip('not currently supported') +@httprettified +@scenario(create_socket) +def test_httpretty_debugs_socket_sendto(context): + "HTTPretty should forward_and_trace socket.sendto" + + expect(context.sock.sendto).when.called.to.throw( + "not connected" + ) + + +@skip('not currently supported') +@httprettified +@scenario(create_socket) +def test_httpretty_debugs_socket_recvfrom(context): + "HTTPretty should forward_and_trace socket.recvfrom" + + expect(context.sock.recvfrom).when.called.to.throw( + "not connected" + ) + + +@skip('not currently supported') +@httprettified +@scenario(create_socket) +def test_httpretty_debugs_socket_recv_into(context): + "HTTPretty should forward_and_trace socket.recv_into" + buf = bytearray() + expect(context.sock.recv_into).when.called_with(buf).to.throw( + "not connected" + ) + + +@skip('not currently supported') +@httprettified +@scenario(create_socket) +def test_httpretty_debugs_socket_recvfrom_into(context): + "HTTPretty should forward_and_trace socket.recvfrom_into" + + expect(context.sock.recvfrom_into).when.called.to.throw( + "not connected" + ) diff --git a/tests/functional/test_decorator.py b/tests/functional/test_decorator.py new file mode 100644 index 0000000..d1f156a --- /dev/null +++ b/tests/functional/test_decorator.py @@ -0,0 +1,116 @@ +# coding: utf-8 +from unittest import TestCase +from sure import expect +from httpretty import httprettified, HTTPretty + +try: + import urllib.request as urllib2 +except ImportError: + import urllib2 + + +@httprettified +def test_decor(): + HTTPretty.register_uri( + HTTPretty.GET, "http://localhost/", + body="glub glub") + + fd = urllib2.urlopen('http://localhost/') + got1 = fd.read() + fd.close() + + expect(got1).to.equal(b'glub glub') + + +@httprettified +class DecoratedNonUnitTest(object): + + def test_fail(self): + raise AssertionError('Tests in this class should not ' + 'be executed by the test runner.') + + def test_decorated(self): + HTTPretty.register_uri( + HTTPretty.GET, "http://localhost/", + body="glub glub") + + fd = urllib2.urlopen('http://localhost/') + got1 = fd.read() + fd.close() + + expect(got1).to.equal(b'glub glub') + + +class NonUnitTestTest(TestCase): + """ + Checks that the test methods in DecoratedNonUnitTest were decorated. + """ + + def test_decorated(self): + DecoratedNonUnitTest().test_decorated() + + +@httprettified +class ClassDecorator(TestCase): + + def test_decorated(self): + HTTPretty.register_uri( + HTTPretty.GET, "http://localhost/", + body="glub glub") + + fd = urllib2.urlopen('http://localhost/') + got1 = fd.read() + fd.close() + + expect(got1).to.equal(b'glub glub') + + def test_decorated2(self): + HTTPretty.register_uri( + HTTPretty.GET, "http://localhost/", + body="buble buble") + + fd = urllib2.urlopen('http://localhost/') + got1 = fd.read() + fd.close() + + expect(got1).to.equal(b'buble buble') + + +@httprettified +class ClassDecoratorWithSetUp(TestCase): + + def setUp(self): + HTTPretty.register_uri( + HTTPretty.GET, "http://localhost/", + responses=[ + HTTPretty.Response("glub glub"), + HTTPretty.Response("buble buble"), + ]) + + def test_decorated(self): + + fd = urllib2.urlopen('http://localhost/') + got1 = fd.read() + fd.close() + + expect(got1).to.equal(b'glub glub') + + fd = urllib2.urlopen('http://localhost/') + got2 = fd.read() + fd.close() + + expect(got2).to.equal(b'buble buble') + + def test_decorated2(self): + + fd = urllib2.urlopen('http://localhost/') + got1 = fd.read() + fd.close() + + expect(got1).to.equal(b'glub glub') + + fd = urllib2.urlopen('http://localhost/') + got2 = fd.read() + fd.close() + + expect(got2).to.equal(b'buble buble') diff --git a/tests/functional/test_fakesocket.py b/tests/functional/test_fakesocket.py new file mode 100644 index 0000000..39d914b --- /dev/null +++ b/tests/functional/test_fakesocket.py @@ -0,0 +1,75 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import functools +import socket + +import mock + + +class FakeSocket(socket.socket): + """ + Just an editable socket factory + It allows mock to patch readonly functions + """ + connect = sendall = lambda *args, **kw: None + + +fake_socket_interupter_flag = {} + + +def recv(flag, size): + """ + Two pass recv implementation + + This implementation will for the first time send something that is smaller than + the asked size passed in argument. + Any further call will just raise RuntimeError + """ + if 'was_here' in flag: + raise RuntimeError('Already sent everything') + else: + flag['was_here'] = None + return 'a' * (size - 1) + + +recv = functools.partial(recv, fake_socket_interupter_flag) + + +@mock.patch('httpretty.old_socket', new=FakeSocket) +def _test_shorten_response(): + u"HTTPretty shouldn't try to read from server when communication is over" + from sure import expect + import httpretty + + fakesocket = httpretty.fakesock.socket(socket.AF_INET, + socket.SOCK_STREAM) + with mock.patch.object(fakesocket.truesock, 'recv', recv): + fakesocket.connect(('localhost', 80)) + fakesocket._true_sendall('WHATEVER') + expect(fakesocket.fd.read()).to.equal( + 'a' * (httpretty.socket_buffer_size - 1)) diff --git a/tests/functional/test_httplib2.py b/tests/functional/test_httplib2.py new file mode 100644 index 0000000..c913d8a --- /dev/null +++ b/tests/functional/test_httplib2.py @@ -0,0 +1,306 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +from __future__ import unicode_literals + +import re +import httplib2 +from freezegun import freeze_time +from sure import expect, within, miliseconds +from httpretty import HTTPretty, httprettified +from httpretty.core import decode_utf8 + + +@httprettified +@within(two=miliseconds) +def test_httpretty_should_mock_a_simple_get_with_httplib2_read(now): + "HTTPretty should mock a simple GET with httplib2.context.http" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/", + body="Find the best daily deals") + + _, got = httplib2.Http().request('http://yipit.com', 'GET') + expect(got).to.equal(b'Find the best daily deals') + + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/') + + +@httprettified +@within(two=miliseconds) +def test_httpretty_provides_easy_access_to_querystrings(now): + "HTTPretty should provide an easy access to the querystring" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/", + body="Find the best daily deals") + + httplib2.Http().request('http://yipit.com?foo=bar&foo=baz&chuck=norris', 'GET') + expect(HTTPretty.last_request.querystring).to.equal({ + 'foo': ['bar', 'baz'], + 'chuck': ['norris'], + }) + + +@httprettified +@freeze_time("2013-10-04 04:20:00") +def test_httpretty_should_mock_headers_httplib2(): + "HTTPretty should mock basic headers with httplib2" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/", + body="this is supposed to be the response", + status=201) + + headers, _ = httplib2.Http().request('http://github.com', 'GET') + expect(headers['status']).to.equal('201') + expect(dict(headers)).to.equal({ + 'content-type': 'text/plain; charset=utf-8', + 'connection': 'close', + 'content-length': '35', + 'status': '201', + 'server': 'Python/HTTPretty', + 'date': 'Fri, 04 Oct 2013 04:20:00 GMT', + }) + + +@httprettified +@freeze_time("2013-10-04 04:20:00") +def test_httpretty_should_allow_adding_and_overwritting_httplib2(): + "HTTPretty should allow adding and overwritting headers with httplib2" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/foo", + body="this is supposed to be the response", + adding_headers={ + 'Server': 'Apache', + 'Content-Length': '27', + 'Content-Type': 'application/json', + }) + + headers, _ = httplib2.Http().request('http://github.com/foo', 'GET') + + expect(dict(headers)).to.equal({ + 'content-type': 'application/json', + 'content-location': 'http://github.com/foo', + 'connection': 'close', + 'content-length': '27', + 'status': '200', + 'server': 'Apache', + 'date': 'Fri, 04 Oct 2013 04:20:00 GMT', + }) + + +@httprettified +@within(two=miliseconds) +def test_httpretty_should_allow_forcing_headers_httplib2(now): + "HTTPretty should allow forcing headers with httplib2" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/foo", + body="this is supposed to be the response", + forcing_headers={ + 'Content-Type': 'application/xml', + }) + + headers, _ = httplib2.Http().request('http://github.com/foo', 'GET') + + expect(dict(headers)).to.equal({ + 'content-location': 'http://github.com/foo', # httplib2 FORCES + # content-location + # even if the + # server does not + # provide it + 'content-type': 'application/xml', + 'status': '200', # httplib2 also ALWAYS put status on headers + }) + + +@httprettified +@freeze_time("2013-10-04 04:20:00") +def test_httpretty_should_allow_adding_and_overwritting_by_kwargs_u2(): + "HTTPretty should allow adding and overwritting headers by keyword args " \ + "with httplib2" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/foo", + body="this is supposed to be the response", + server='Apache', + content_length='27', + content_type='application/json') + + headers, _ = httplib2.Http().request('http://github.com/foo', 'GET') + + expect(dict(headers)).to.equal({ + 'content-type': 'application/json', + 'content-location': 'http://github.com/foo', # httplib2 FORCES + # content-location + # even if the + # server does not + # provide it + 'connection': 'close', + 'content-length': '27', + 'status': '200', + 'server': 'Apache', + 'date': 'Fri, 04 Oct 2013 04:20:00 GMT', + }) + + +@httprettified +@within(two=miliseconds) +def test_rotating_responses_with_httplib2(now): + "HTTPretty should support rotating responses with httplib2" + + HTTPretty.register_uri( + HTTPretty.GET, "https://api.yahoo.com/test", + responses=[ + HTTPretty.Response(body="first response", status=201), + HTTPretty.Response(body='second and last response', status=202), + ]) + + headers1, body1 = httplib2.Http().request( + 'https://api.yahoo.com/test', 'GET') + + expect(headers1['status']).to.equal('201') + expect(body1).to.equal(b'first response') + + headers2, body2 = httplib2.Http().request( + 'https://api.yahoo.com/test', 'GET') + + expect(headers2['status']).to.equal('202') + expect(body2).to.equal(b'second and last response') + + headers3, body3 = httplib2.Http().request( + 'https://api.yahoo.com/test', 'GET') + + expect(headers3['status']).to.equal('202') + expect(body3).to.equal(b'second and last response') + + +@httprettified +@within(two=miliseconds) +def test_can_inspect_last_request(now): + "HTTPretty.last_request is a mimetools.Message request from last match" + + HTTPretty.register_uri(HTTPretty.POST, "http://api.github.com/", + body='{"repositories": ["HTTPretty", "lettuce"]}') + + headers, body = httplib2.Http().request( + 'http://api.github.com', 'POST', + body='{"username": "gabrielfalcao"}', + headers={ + 'content-type': 'text/json', + }, + ) + + expect(HTTPretty.last_request.method).to.equal('POST') + expect(HTTPretty.last_request.body).to.equal( + b'{"username": "gabrielfalcao"}', + ) + expect(HTTPretty.last_request.headers['content-type']).to.equal( + 'text/json', + ) + expect(body).to.equal(b'{"repositories": ["HTTPretty", "lettuce"]}') + + +@httprettified +@within(two=miliseconds) +def test_can_inspect_last_request_with_ssl(now): + "HTTPretty.last_request is recorded even when mocking 'https' (SSL)" + + HTTPretty.register_uri(HTTPretty.POST, "https://secure.github.com/", + body='{"repositories": ["HTTPretty", "lettuce"]}') + + headers, body = httplib2.Http().request( + 'https://secure.github.com', 'POST', + body='{"username": "gabrielfalcao"}', + headers={ + 'content-type': 'text/json', + }, + ) + + expect(HTTPretty.last_request.method).to.equal('POST') + expect(HTTPretty.last_request.body).to.equal( + b'{"username": "gabrielfalcao"}', + ) + expect(HTTPretty.last_request.headers['content-type']).to.equal( + 'text/json', + ) + expect(body).to.equal(b'{"repositories": ["HTTPretty", "lettuce"]}') + + +@httprettified +@within(two=miliseconds) +def test_httpretty_ignores_querystrings_from_registered_uri(now): + "Registering URIs with query string cause them to be ignored" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/?id=123", + body="Find the best daily deals") + + _, got = httplib2.Http().request('http://yipit.com/?id=123', 'GET') + + expect(got).to.equal(b'Find the best daily deals') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/?id=123') + + +@httprettified +@within(two=miliseconds) +def test_callback_response(now): + ("HTTPretty should call a callback function to be set as the body with" + " httplib2") + + def request_callback(request, uri, headers): + return [200, headers, "The {} response from {}".format(decode_utf8(request.method), uri)] + + HTTPretty.register_uri( + HTTPretty.GET, "https://api.yahoo.com/test", + body=request_callback) + + headers1, body1 = httplib2.Http().request( + 'https://api.yahoo.com/test', 'GET') + + expect(body1).to.equal(b"The GET response from https://api.yahoo.com/test") + + HTTPretty.register_uri( + HTTPretty.POST, "https://api.yahoo.com/test_post", + body=request_callback) + + headers2, body2 = httplib2.Http().request( + 'https://api.yahoo.com/test_post', 'POST') + + expect(body2).to.equal(b"The POST response from https://api.yahoo.com/test_post") + + +@httprettified +def test_httpretty_should_allow_registering_regexes(): + "HTTPretty should allow registering regexes with httplib2" + + HTTPretty.register_uri( + HTTPretty.GET, + 'http://api.yipit.com/v1/deal;brand=gap', + body="Found brand", + ) + + response, body = httplib2.Http().request('http://api.yipit.com/v1/deal;brand=gap', 'GET') + expect(body).to.equal(b'Found brand') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/v1/deal;brand=gap') diff --git a/tests/functional/test_passthrough.py b/tests/functional/test_passthrough.py new file mode 100644 index 0000000..47c9e79 --- /dev/null +++ b/tests/functional/test_passthrough.py @@ -0,0 +1,78 @@ +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +import requests +import httpretty + +from sure import expect + + +def http(): + sess = requests.Session() + adapter = requests.adapters.HTTPAdapter(pool_connections=1, pool_maxsize=1) + sess.mount('http://', adapter) + sess.mount('https://', adapter) + return sess + + +def test_http_passthrough(): + url = 'http://httpbin.org/status/200' + response1 = http().get(url) + + response1 = http().get(url) + + httpretty.enable(allow_net_connect=False, verbose=True) + httpretty.register_uri(httpretty.GET, 'http://google.com/', body="Not Google") + httpretty.register_uri(httpretty.GET, url, body="mocked") + + response2 = http().get('http://google.com/') + expect(response2.content).to.equal(b'Not Google') + + response3 = http().get(url) + response3.content.should.equal(b"mocked") + + httpretty.disable() + + response4 = http().get(url) + (response4.content).should.equal(response1.content) + + +def test_https_passthrough(): + url = 'https://httpbin.org/status/200' + + response1 = http().get(url) + + httpretty.enable(allow_net_connect=False, verbose=True) + httpretty.register_uri(httpretty.GET, 'https://google.com/', body="Not Google") + httpretty.register_uri(httpretty.GET, url, body="mocked") + + response2 = http().get('https://google.com/') + expect(response2.content).to.equal(b'Not Google') + + response3 = http().get(url) + (response3.text).should.equal('mocked') + + httpretty.disable() + + response4 = http().get(url) + (response4.content).should.equal(response1.content) diff --git a/tests/functional/test_requests.py b/tests/functional/test_requests.py new file mode 100644 index 0000000..752428b --- /dev/null +++ b/tests/functional/test_requests.py @@ -0,0 +1,949 @@ +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import os +import re +import json +import requests +import signal +import httpretty + +from freezegun import freeze_time +from contextlib import contextmanager +from sure import within, miliseconds, expect +from tornado import version as tornado_version +from httpretty import HTTPretty, httprettified +from httpretty.core import decode_utf8 + +from tests.functional.base import FIXTURE_FILE, use_tornado_server + +from tests.compat import Mock + + +try: + advance_iterator = next +except NameError: + def advance_iterator(it): + return it.next() + + +next = advance_iterator + +server_url = lambda path, port: "http://localhost:{}/{}".format(port, path.lstrip('/')) + + +@httprettified +@within(two=miliseconds) +def test_httpretty_should_mock_a_simple_get_with_requests_read(now): + "HTTPretty should mock a simple GET with requests.get" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/", + body="Find the best daily deals") + + response = requests.get('http://yipit.com') + expect(response.text).to.equal('Find the best daily deals') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/') + + +@httprettified +@within(two=miliseconds) +def test_hostname_case_insensitive(now): + "HTTPretty should match the hostname case insensitive" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit/", + body="Find the best daily deals") + + response = requests.get('http://YIPIT') + expect(response.text).to.equal('Find the best daily deals') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/') + + +@httprettified +@within(two=miliseconds) +def test_httpretty_provides_easy_access_to_querystrings(now): + "HTTPretty should provide an easy access to the querystring" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/", + body="Find the best daily deals") + + requests.get('http://yipit.com/?foo=bar&foo=baz&chuck=norris') + expect(HTTPretty.last_request.querystring).to.equal({ + 'foo': ['bar', 'baz'], + 'chuck': ['norris'], + }) + + +@httprettified +@freeze_time("2013-10-04 04:20:00") +def test_httpretty_should_mock_headers_requests(): + "HTTPretty should mock basic headers with requests" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/", + body="this is supposed to be the response", + status=201) + + response = requests.get('http://github.com') + expect(response.status_code).to.equal(201) + + expect(dict(response.headers)).to.equal({ + 'content-type': 'text/plain; charset=utf-8', + 'connection': 'close', + 'content-length': '35', + 'status': '201', + 'server': 'Python/HTTPretty', + 'date': 'Fri, 04 Oct 2013 04:20:00 GMT', + }) + + +@httprettified +@freeze_time("2013-10-04 04:20:00") +def test_httpretty_should_allow_adding_and_overwritting_requests(): + "HTTPretty should allow adding and overwritting headers with requests" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/foo", + body="this is supposed to be the response", + adding_headers={ + 'Server': 'Apache', + 'Content-Length': '27', + 'Content-Type': 'application/json', + }) + + response = requests.get('http://github.com/foo') + + expect(dict(response.headers)).to.equal({ + 'content-type': 'application/json', + 'connection': 'close', + 'content-length': '27', + 'status': '200', + 'server': 'Apache', + 'date': 'Fri, 04 Oct 2013 04:20:00 GMT', + }) + + +@httprettified +@within(two=miliseconds) +def test_httpretty_should_allow_forcing_headers_requests(now): + "HTTPretty should allow forcing headers with requests" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/foo", + body="", + forcing_headers={ + 'Content-Type': 'application/xml', + 'Content-Length': '19', + }) + + response = requests.get('http://github.com/foo') + + expect(dict(response.headers)).to.equal({ + 'content-type': 'application/xml', + 'content-length': '19', + }) + + +@httprettified +@freeze_time("2013-10-04 04:20:00") +def test_httpretty_should_allow_adding_and_overwritting_by_kwargs_u2(): + "HTTPretty should allow adding and overwritting headers by keyword args " \ + "with requests" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/foo", + body="this is supposed to be the response", + server='Apache', + content_length='27', + content_type='application/json') + + response = requests.get('http://github.com/foo') + + expect(dict(response.headers)).to.equal({ + 'content-type': 'application/json', + 'connection': 'close', + 'content-length': '27', + 'status': '200', + 'server': 'Apache', + 'date': 'Fri, 04 Oct 2013 04:20:00 GMT', + }) + + +@httprettified +@within(two=miliseconds) +def test_rotating_responses_with_requests(now): + "HTTPretty should support rotating responses with requests" + + HTTPretty.register_uri( + HTTPretty.GET, "https://api.yahoo.com/test", + responses=[ + HTTPretty.Response(body=b"first response", status=201), + HTTPretty.Response(body=b'second and last response', status=202), + ]) + + response1 = requests.get( + 'https://api.yahoo.com/test') + + expect(response1.status_code).to.equal(201) + expect(response1.text).to.equal('first response') + + response2 = requests.get( + 'https://api.yahoo.com/test') + + expect(response2.status_code).to.equal(202) + expect(response2.text).to.equal('second and last response') + + response3 = requests.get( + 'https://api.yahoo.com/test') + + expect(response3.status_code).to.equal(202) + expect(response3.text).to.equal('second and last response') + + +@httprettified +@within(two=miliseconds) +def test_can_inspect_last_request(now): + "HTTPretty.last_request is a mimetools.Message request from last match" + + HTTPretty.register_uri(HTTPretty.POST, "http://api.github.com/", + body='{"repositories": ["HTTPretty", "lettuce"]}') + + response = requests.post( + 'http://api.github.com', + '{"username": "gabrielfalcao"}', + headers={ + 'content-type': 'text/json', + }, + ) + + expect(HTTPretty.last_request.method).to.equal('POST') + expect(HTTPretty.last_request.body).to.equal( + b'{"username": "gabrielfalcao"}', + ) + expect(HTTPretty.last_request.headers['content-type']).to.equal( + 'text/json', + ) + expect(response.json()).to.equal({"repositories": ["HTTPretty", "lettuce"]}) + + +@httprettified +@within(two=miliseconds) +def test_can_inspect_last_request_with_ssl(now): + "HTTPretty.last_request is recorded even when mocking 'https' (SSL)" + + HTTPretty.register_uri(HTTPretty.POST, "https://secure.github.com/", + body='{"repositories": ["HTTPretty", "lettuce"]}') + + response = requests.post( + 'https://secure.github.com', + '{"username": "gabrielfalcao"}', + headers={ + 'content-type': 'text/json', + }, + ) + + expect(HTTPretty.last_request.method).to.equal('POST') + expect(HTTPretty.last_request.body).to.equal( + b'{"username": "gabrielfalcao"}', + ) + expect(HTTPretty.last_request.headers['content-type']).to.equal( + 'text/json', + ) + expect(response.json()).to.equal({"repositories": ["HTTPretty", "lettuce"]}) + + +@httprettified +@within(two=miliseconds) +def test_httpretty_ignores_querystrings_from_registered_uri(now): + "HTTPretty should ignore querystrings from the registered uri (requests library)" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/?id=123", + body=b"Find the best daily deals") + + response = requests.get('http://yipit.com/', params={'id': 123}) + expect(response.text).to.equal('Find the best daily deals') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/?id=123') + + +@httprettified +@within(five=miliseconds) +def test_streaming_responses(now): + """ + Mock a streaming HTTP response, like those returned by the Twitter streaming + API. + """ + + @contextmanager + def in_time(time, message): + """ + A context manager that uses signals to force a time limit in tests + (unlike the `@within` decorator, which only complains afterward), or + raise an AssertionError. + """ + def handler(signum, frame): + raise AssertionError(message) + signal.signal(signal.SIGALRM, handler) + signal.setitimer(signal.ITIMER_REAL, time) + yield + signal.setitimer(signal.ITIMER_REAL, 0) + + # XXX this obviously isn't a fully functional twitter streaming client! + twitter_response_lines = [ + b'{"text":"If \\"for the boobs\\" requests to follow me one more time I\'m calling the police. http://t.co/a0mDEAD8"}\r\n', + b'\r\n', + b'{"text":"RT @onedirection: Thanks for all your # FollowMe1D requests Directioners! We\u2019ll be following 10 people throughout the day starting NOW. G ..."}\r\n' + ] + + TWITTER_STREAMING_URL = "https://stream.twitter.com/1/statuses/filter.json" + + HTTPretty.register_uri(HTTPretty.POST, TWITTER_STREAMING_URL, + body=(l for l in twitter_response_lines), + streaming=True) + + # taken from the requests docs + + # test iterating by line + # Http://docs.python-requests.org/en/latest/user/advanced/# streaming-requests + response = requests.post(TWITTER_STREAMING_URL, data={'track': 'requests'}, + auth=('username', 'password'), stream=True) + + line_iter = response.iter_lines() + with in_time(0.01, 'Iterating by line is taking forever!'): + for i in range(len(twitter_response_lines)): + expect(next(line_iter).strip()).to.equal( + twitter_response_lines[i].strip()) + + HTTPretty.register_uri(HTTPretty.POST, TWITTER_STREAMING_URL, + body=(l for l in twitter_response_lines), + streaming=True) + # test iterating by line after a second request + response = requests.post( + TWITTER_STREAMING_URL, + data={ + 'track': 'requests' + }, + auth=('username', 'password'), + stream=True, + ) + + line_iter = response.iter_lines() + with in_time(0.01, 'Iterating by line is taking forever the second time ' + 'around!'): + for i in range(len(twitter_response_lines)): + expect(next(line_iter).strip()).to.equal( + twitter_response_lines[i].strip()) + + HTTPretty.register_uri(HTTPretty.POST, TWITTER_STREAMING_URL, + body=(l for l in twitter_response_lines), + streaming=True) + # test iterating by char + response = requests.post( + TWITTER_STREAMING_URL, + data={ + 'track': 'requests' + }, + auth=('username', 'password'), + stream=True + ) + + twitter_expected_response_body = b''.join(twitter_response_lines) + with in_time(0.02, 'Iterating by char is taking forever!'): + twitter_body = b''.join(c for c in response.iter_content(chunk_size=1)) + + expect(twitter_body).to.equal(twitter_expected_response_body) + + # test iterating by chunks larger than the stream + HTTPretty.register_uri(HTTPretty.POST, TWITTER_STREAMING_URL, + body=(l for l in twitter_response_lines), + streaming=True) + response = requests.post(TWITTER_STREAMING_URL, data={'track': 'requests'}, + auth=('username', 'password'), stream=True) + + with in_time(0.02, 'Iterating by large chunks is taking forever!'): + twitter_body = b''.join(c for c in + response.iter_content(chunk_size=1024)) + + expect(twitter_body).to.equal(twitter_expected_response_body) + + +@httprettified +def test_multiline(): + url = 'https://httpbin.org/post' + data = b'content=Im\r\na multiline\r\n\r\nsentence\r\n' + headers = { + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Accept': 'text/plain', + } + HTTPretty.register_uri( + HTTPretty.POST, + url, + ) + response = requests.post(url, data=data, headers=headers) + + expect(response.status_code).to.equal(200) + expect(HTTPretty.last_request.method).to.equal('POST') + expect(HTTPretty.last_request.url).to.equal('https://httpbin.org/post') + expect(HTTPretty.last_request.protocol).to.equal('https') + expect(HTTPretty.last_request.path).to.equal('/post') + expect(HTTPretty.last_request.body).to.equal(data) + expect(HTTPretty.last_request.headers['content-length']).to.equal('37') + expect(HTTPretty.last_request.headers['content-type']).to.equal('application/x-www-form-urlencoded; charset=utf-8') + expect(len(HTTPretty.latest_requests)).to.equal(2) + + +@httprettified +def test_octet_stream(): + url = 'https://httpbin.org/post' + data = b"\xf5\x00\x00\x00" # utf-8 with invalid start byte + headers = { + 'Content-Type': 'application/octet-stream', + } + HTTPretty.register_uri( + HTTPretty.POST, + url, + ) + response = requests.post(url, data=data, headers=headers) + + expect(response.status_code).to.equal(200) + expect(HTTPretty.last_request.method).to.equal('POST') + expect(HTTPretty.last_request.url).to.equal('https://httpbin.org/post') + expect(HTTPretty.last_request.protocol).to.equal('https') + expect(HTTPretty.last_request.path).to.equal('/post') + expect(HTTPretty.last_request.body).to.equal(data) + expect(HTTPretty.last_request.headers['content-length']).to.equal('4') + expect(HTTPretty.last_request.headers['content-type']).to.equal('application/octet-stream') + expect(len(HTTPretty.latest_requests)).to.equal(2) + + +@httprettified +def test_multipart(): + url = 'https://httpbin.org/post' + data = b'--xXXxXXyYYzzz\r\nContent-Disposition: form-data; name="content"\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: 68\r\n\r\nAction: comment\nText: Comment with attach\nAttachment: x1.txt, x2.txt\r\n--xXXxXXyYYzzz\r\nContent-Disposition: form-data; name="attachment_2"; filename="x.txt"\r\nContent-Type: text/plain\r\nContent-Length: 4\r\n\r\nbye\n\r\n--xXXxXXyYYzzz\r\nContent-Disposition: form-data; name="attachment_1"; filename="x.txt"\r\nContent-Type: text/plain\r\nContent-Length: 4\r\n\r\nbye\n\r\n--xXXxXXyYYzzz--\r\n' + headers = {'Content-Length': '495', 'Content-Type': 'multipart/form-data; boundary=xXXxXXyYYzzz', 'Accept': 'text/plain'} + HTTPretty.register_uri( + HTTPretty.POST, + url, + ) + response = requests.post(url, data=data, headers=headers) + expect(response.status_code).to.equal(200) + expect(HTTPretty.last_request.method).to.equal('POST') + expect(HTTPretty.last_request.url).to.equal('https://httpbin.org/post') + expect(HTTPretty.last_request.protocol).to.equal('https') + expect(HTTPretty.last_request.path).to.equal('/post') + expect(HTTPretty.last_request.body).to.equal(data) + expect(HTTPretty.last_request.headers['content-length']).to.equal('495') + expect(HTTPretty.last_request.headers['content-type']).to.equal('multipart/form-data; boundary=xXXxXXyYYzzz') + expect(len(HTTPretty.latest_requests)).to.equal(2) + + +@httprettified +@within(two=miliseconds) +def test_callback_response(now): + ("HTTPretty should call a callback function and set its return value as the body of the response" + " requests") + + def request_callback(request, uri, headers): + return [200, headers, "The {} response from {}".format(decode_utf8(request.method), uri)] + + HTTPretty.register_uri( + HTTPretty.GET, "https://api.yahoo.com/test", + body=request_callback) + + response = requests.get('https://api.yahoo.com/test') + + expect(response.text).to.equal("The GET response from https://api.yahoo.com/test") + + HTTPretty.register_uri( + HTTPretty.POST, "https://api.yahoo.com/test_post", + body=request_callback) + + response = requests.post( + "https://api.yahoo.com/test_post", + {"username": "gabrielfalcao"} + ) + + expect(response.text).to.equal("The POST response from https://api.yahoo.com/test_post") + + +@httprettified +@within(two=miliseconds) +def test_callback_body_remains_callable_for_any_subsequent_requests(now): + ("HTTPretty should call a callback function more than one" + " requests") + + def request_callback(request, uri, headers): + return [200, headers, "The {} response from {}".format(decode_utf8(request.method), uri)] + + HTTPretty.register_uri( + HTTPretty.GET, "https://api.yahoo.com/test", + body=request_callback) + + response = requests.get('https://api.yahoo.com/test') + expect(response.text).to.equal("The GET response from https://api.yahoo.com/test") + + response = requests.get('https://api.yahoo.com/test') + expect(response.text).to.equal("The GET response from https://api.yahoo.com/test") + + +@httprettified +@within(two=miliseconds) +def test_callback_setting_headers_and_status_response(now): + ("HTTPretty should call a callback function and uses it retur tuple as status code, headers and body" + " requests") + + def request_callback(request, uri, headers): + headers.update({'a': 'b'}) + return [418, headers, "The {} response from {}".format(decode_utf8(request.method), uri)] + + HTTPretty.register_uri( + HTTPretty.GET, "https://api.yahoo.com/test", + body=request_callback) + + response = requests.get('https://api.yahoo.com/test') + expect(response.text).to.equal("The GET response from https://api.yahoo.com/test") + expect(response.headers).to.have.key('a').being.equal("b") + expect(response.status_code).to.equal(418) + + HTTPretty.register_uri( + HTTPretty.POST, "https://api.yahoo.com/test_post", + body=request_callback) + + response = requests.post( + "https://api.yahoo.com/test_post", + {"username": "gabrielfalcao"} + ) + + expect(response.text).to.equal("The POST response from https://api.yahoo.com/test_post") + expect(response.headers).to.have.key('a').being.equal("b") + expect(response.status_code).to.equal(418) + + +@httprettified +def test_httpretty_should_respect_matcher_priority(): + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r".*"), + body='high priority', + priority=5, + ) + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r".+"), + body='low priority', + priority=0, + ) + response = requests.get('http://api.yipit.com/v1/') + expect(response.text).to.equal('high priority') + + +@httprettified +@within(two=miliseconds) +def test_callback_setting_content_length_on_head(now): + ("HTTPretty should call a callback function, use it's return tuple as status code, headers and body" + " requests and respect the content-length header when responding to HEAD") + + def request_callback(request, uri, headers): + headers.update({'content-length': 12345}) + return [200, headers, ""] + + HTTPretty.register_uri( + HTTPretty.HEAD, "https://api.yahoo.com/test", + body=request_callback) + + response = requests.head('https://api.yahoo.com/test') + expect(response.headers).to.have.key('content-length').being.equal("12345") + expect(response.status_code).to.equal(200) + + +@httprettified +def test_httpretty_should_allow_registering_regexes_and_give_a_proper_match_to_the_callback(): + "HTTPretty should allow registering regexes with requests and giva a proper match to the callback" + + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r"https://api.yipit.com/v1/deal;brand=(?P\w+)"), + body=lambda method, uri, headers: [200, headers, uri] + ) + + response = requests.get('https://api.yipit.com/v1/deal;brand=gap?first_name=chuck&last_name=norris') + + expect(response.text).to.equal('https://api.yipit.com/v1/deal;brand=gap?first_name=chuck&last_name=norris') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/v1/deal;brand=gap?first_name=chuck&last_name=norris') + + +@httprettified +def test_httpretty_should_allow_registering_regexes(): + "HTTPretty should allow registering regexes with requests" + + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r"https://api.yipit.com/v1/deal;brand=(?P\w+)"), + body="Found brand", + ) + + response = requests.get('https://api.yipit.com/v1/deal;brand=gap?first_name=chuck&last_name=norris' + ) + expect(response.text).to.equal('Found brand') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/v1/deal;brand=gap?first_name=chuck&last_name=norris') + + +@httprettified +def test_httpretty_provides_easy_access_to_querystrings_with_regexes(): + "HTTPretty should match regexes even if they have a different querystring" + + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r"https://api.yipit.com/v1/(?P\w+)/$"), + body="Find the best daily deals" + ) + + response = requests.get('https://api.yipit.com/v1/deals/?foo=bar&foo=baz&chuck=norris') + expect(response.text).to.equal("Find the best daily deals") + expect(HTTPretty.last_request.querystring).to.equal({ + 'foo': ['bar', 'baz'], + 'chuck': ['norris'], + }) + + +@httprettified(verbose=True) +def test_httpretty_allows_to_chose_if_querystring_should_be_matched(): + "HTTPretty should provide a way to not match regexes that have a different querystring" + + HTTPretty.register_uri( + HTTPretty.GET, + "http://localhost:9090", + ) + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r"http://localhost:9090/what/?$"), + body="Nudge, nudge, wink, wink. Know what I mean?", + match_querystring=True + ) + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r"http://localhost:9090/what.*[?]?.*"), + body="Different", + match_querystring=False + ) + response = requests.get('http://localhost:9090/what/') + expect(response.text).to.equal('Nudge, nudge, wink, wink. Know what I mean?') + + response = requests.get('http://localhost:9090/what/', params={'flying': 'coconuts'}) + expect(response.text).to.not_be.equal('Nudge, nudge, wink, wink. Know what I mean?') + + +@httprettified +def test_httpretty_should_allow_multiple_methods_for_the_same_uri(): + "HTTPretty should allow registering multiple methods for the same uri" + + url = 'http://test.com/test' + methods = ['GET', 'POST', 'PUT', 'OPTIONS'] + for method in methods: + HTTPretty.register_uri( + getattr(HTTPretty, method), + url, + method + ) + + for method in methods: + request_action = getattr(requests, method.lower()) + expect(request_action(url).text).to.equal(method) + + +@httprettified +def test_httpretty_should_allow_registering_regexes_with_streaming_responses(): + "HTTPretty should allow registering regexes with streaming responses" + + os.environ['DEBUG'] = 'true' + + def my_callback(request, url, headers): + request.body.should.equal(b'hithere') + return 200, headers, "Received" + + HTTPretty.register_uri( + HTTPretty.POST, + re.compile(r"https://api.yipit.com/v1/deal;brand=(?P\w+)"), + body=my_callback, + ) + + def gen(): + yield b'hi' + yield b'there' + + response = requests.post( + 'https://api.yipit.com/v1/deal;brand=gap?first_name=chuck&last_name=norris', + data=gen(), + ) + expect(response.content).to.equal(b"Received") + expect(HTTPretty.last_request.method).to.equal('POST') + expect(HTTPretty.last_request.path).to.equal('/v1/deal;brand=gap?first_name=chuck&last_name=norris') + + +@httprettified +def test_httpretty_should_allow_multiple_responses_with_multiple_methods(): + "HTTPretty should allow multiple responses when binding multiple methods to the same uri" + + url = 'http://test.com/list' + + # add get responses + HTTPretty.register_uri( + HTTPretty.GET, url, + responses=[ + HTTPretty.Response(body='a'), + HTTPretty.Response(body='b'), + ] + ) + + # add post responses + HTTPretty.register_uri( + HTTPretty.POST, url, + responses=[ + HTTPretty.Response(body='c'), + HTTPretty.Response(body='d'), + ] + ) + + expect(requests.get(url).text).to.equal('a') + expect(requests.post(url).text).to.equal('c') + + expect(requests.get(url).text).to.equal('b') + expect(requests.get(url).text).to.equal('b') + expect(requests.get(url).text).to.equal('b') + + expect(requests.post(url).text).to.equal('d') + expect(requests.post(url).text).to.equal('d') + expect(requests.post(url).text).to.equal('d') + + +@httprettified +def test_httpretty_should_normalize_url_patching(): + "HTTPretty should normalize all url patching" + + HTTPretty.register_uri( + HTTPretty.GET, + "http://yipit.com/foo(bar)", + body="Find the best daily deals") + + response = requests.get('http://yipit.com/foo%28bar%29') + expect(response.text).to.equal('Find the best daily deals') + + +@httprettified +def test_lack_of_trailing_slash(): + ("HTTPretty should automatically append a slash to given urls") + url = 'http://www.youtube.com' + HTTPretty.register_uri(HTTPretty.GET, url, body='') + response = requests.get(url) + response.status_code.should.equal(200) + + +@httprettified +def test_unicode_querystrings(): + ("Querystrings should accept unicode characters") + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/login", + body="Find the best daily deals") + requests.get('http://yipit.com/login?user=Gabriel+Falcão') + expect(HTTPretty.last_request.querystring['user'][0]).should.be.equal('Gabriel Falcão') + + +@use_tornado_server +def test_recording_calls(port): + ("HTTPretty should be able to record calls") + # Given a destination path: + destination = FIXTURE_FILE("recording-1.json") + + # When I record some calls + with HTTPretty.record(destination): + requests.get(server_url("/foobar?name=Gabriel&age=25", port)) + requests.post(server_url("/foobar", port), + data=json.dumps({'test': '123'}), + headers={"Test": "foobar"}) + + # Then the destination path should exist + os.path.exists(destination).should.be.true + + # And the contents should be json + raw = open(destination).read() + json.loads.when.called_with(raw).should_not.throw(ValueError) + + # And the contents should be expected + data = json.loads(raw) + data.should.be.a(list) + data.should.have.length_of(2) + + # And the responses should have the expected keys + response = data[0] + response.should.have.key("request").being.length_of(5) + response.should.have.key("response").being.length_of(3) + + response['request'].should.have.key("method").being.equal("GET") + response['request'].should.have.key("headers").being.a(dict) + response['request'].should.have.key("querystring").being.equal({ + "age": [ + "25" + ], + "name": [ + "Gabriel" + ] + }) + response['response'].should.have.key("status").being.equal(200) + response['response'].should.have.key("body").being.an(str) + response['response'].should.have.key("headers").being.a(dict) + # older urllib3 had a bug where header keys were lower-cased: + # https://github.com/shazow/urllib3/issues/236 + # cope with that + if 'server' in response['response']["headers"]: + response['response']["headers"]["Server"] = response['response']["headers"].pop("server") + response['response']["headers"].should.have.key("Server").being.equal("TornadoServer/" + tornado_version) + + # And When I playback the previously recorded calls + with HTTPretty.playback(destination): + # And make the expected requests + response1 = requests.get(server_url("/foobar?name=Gabriel&age=25", port)) + response2 = requests.post( + server_url("/foobar", port), + data=json.dumps({'test': '123'}), + headers={"Test": "foobar"}, + ) + + # Then the responses should be the expected + response1.json().should.equal({"foobar": {"age": "25", "name": "Gabriel"}}) + response2.json()["foobar"].should.equal({}) + response2.json()["req_body"].should.equal(json.dumps({"test": "123"})) + response2.json()["req_headers"].should.have.key("Test") + response2.json()["req_headers"]["Test"].should.equal("foobar") + + +@httprettified +def test_py26_callback_response(): + ("HTTPretty should call a callback function *once* and set its return value" + " as the body of the response requests") + + def _request_callback(request, uri, headers): + return [200, headers, "The {} response from {}".format(decode_utf8(request.method), uri)] + + request_callback = Mock() + request_callback.side_effect = _request_callback + + HTTPretty.register_uri( + HTTPretty.POST, "https://api.yahoo.com/test_post", + body=request_callback) + + requests.post( + "https://api.yahoo.com/test_post", + {"username": "gabrielfalcao"} + ) + os.environ['STOP'] = 'true' + expect(request_callback.call_count).equal(1) + + +@httprettified +def test_httpretty_should_work_with_non_standard_ports(): + "HTTPretty should work with a non-standard port number" + + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r"https://api.yipit.com:1234/v1/deal;brand=(?P\w+)"), + body=lambda method, uri, headers: [200, headers, uri] + ) + HTTPretty.register_uri( + HTTPretty.POST, + "https://asdf.com:666/meow", + body=lambda method, uri, headers: [200, headers, uri] + ) + + response = requests.get('https://api.yipit.com:1234/v1/deal;brand=gap?first_name=chuck&last_name=norris') + + expect(response.text).to.equal('https://api.yipit.com:1234/v1/deal;brand=gap?first_name=chuck&last_name=norris') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/v1/deal;brand=gap?first_name=chuck&last_name=norris') + + response = requests.post('https://asdf.com:666/meow') + + expect(response.text).to.equal('https://asdf.com:666/meow') + expect(HTTPretty.last_request.method).to.equal('POST') + expect(HTTPretty.last_request.path).to.equal('/meow') + + +@httprettified +def test_httpretty_reset_by_switching_protocols_for_same_port(): + "HTTPretty should reset protocol/port associations" + + HTTPretty.register_uri( + HTTPretty.GET, + "http://api.yipit.com:1234/v1/deal", + body=lambda method, uri, headers: [200, headers, uri] + ) + + response = requests.get('http://api.yipit.com:1234/v1/deal') + + expect(response.text).to.equal('http://api.yipit.com:1234/v1/deal') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/v1/deal') + + HTTPretty.reset() + + HTTPretty.register_uri( + HTTPretty.GET, + "https://api.yipit.com:1234/v1/deal", + body=lambda method, uri, headers: [200, headers, uri] + ) + + response = requests.get('https://api.yipit.com:1234/v1/deal') + + expect(response.text).to.equal('https://api.yipit.com:1234/v1/deal') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/v1/deal') + + +@httprettified +def test_httpretty_should_allow_registering_regexes_with_port_and_give_a_proper_match_to_the_callback(): + "HTTPretty should allow registering regexes with requests and giva a proper match to the callback" + + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r"https://api.yipit.com:1234/v1/deal;brand=(?P\w+)"), + body=lambda method, uri, headers: [200, headers, uri] + ) + + response = requests.get('https://api.yipit.com:1234/v1/deal;brand=gap?first_name=chuck&last_name=norris') + + expect(response.text).to.equal('https://api.yipit.com:1234/v1/deal;brand=gap?first_name=chuck&last_name=norris') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/v1/deal;brand=gap?first_name=chuck&last_name=norris') + + +@httprettified +def test_httpretty_should_handle_paths_starting_with_two_slashes(): + "HTTPretty should handle URLs with paths starting with //" + + HTTPretty.register_uri( + HTTPretty.GET, "http://example.com//foo", + body="Find the best foo" + ) + + response = requests.get('http://example.com//foo') + expect(response.text).to.equal('Find the best foo') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('//foo') diff --git a/tests/functional/test_urllib2.py b/tests/functional/test_urllib2.py new file mode 100644 index 0000000..9c8ff39 --- /dev/null +++ b/tests/functional/test_urllib2.py @@ -0,0 +1,341 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +from __future__ import unicode_literals + +import re +try: + from urllib.request import urlopen + import urllib.request as urllib2 +except ImportError: + import urllib2 + urlopen = urllib2.urlopen + +from freezegun import freeze_time +from sure import within, miliseconds +from httpretty import HTTPretty, httprettified +from httpretty.core import decode_utf8 + + +@httprettified +@within(two=miliseconds) +def test_httpretty_should_mock_a_simple_get_with_urllib2_read(): + "HTTPretty should mock a simple GET with urllib2.read()" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/", + body="Find the best daily deals") + + fd = urlopen('http://yipit.com') + got = fd.read() + fd.close() + + got.should.equal(b'Find the best daily deals') + + +@httprettified +@within(two=miliseconds) +def test_httpretty_provides_easy_access_to_querystrings(now): + "HTTPretty should provide an easy access to the querystring" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/", + body="Find the best daily deals") + + fd = urllib2.urlopen('http://yipit.com/?foo=bar&foo=baz&chuck=norris') + fd.read() + fd.close() + + HTTPretty.last_request.querystring.should.equal({ + 'foo': ['bar', 'baz'], + 'chuck': ['norris'], + }) + + +@httprettified +@freeze_time("2013-10-04 04:20:00") +def test_httpretty_should_mock_headers_urllib2(): + "HTTPretty should mock basic headers with urllib2" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/", + body="this is supposed to be the response", + status=201) + + request = urlopen('http://github.com') + + headers = dict(request.headers) + request.close() + + request.code.should.equal(201) + headers.should.equal({ + 'content-type': 'text/plain; charset=utf-8', + 'connection': 'close', + 'content-length': '35', + 'status': '201', + 'server': 'Python/HTTPretty', + 'date': 'Fri, 04 Oct 2013 04:20:00 GMT', + }) + + +@httprettified +@freeze_time("2013-10-04 04:20:00") +def test_httpretty_should_allow_adding_and_overwritting_urllib2(): + "HTTPretty should allow adding and overwritting headers with urllib2" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/", + body="this is supposed to be the response", + adding_headers={ + 'Server': 'Apache', + 'Content-Length': '27', + 'Content-Type': 'application/json', + }) + + request = urlopen('http://github.com') + headers = dict(request.headers) + request.close() + + request.code.should.equal(200) + headers.should.equal({ + 'content-type': 'application/json', + 'connection': 'close', + 'content-length': '27', + 'status': '200', + 'server': 'Apache', + 'date': 'Fri, 04 Oct 2013 04:20:00 GMT', + }) + + +@httprettified +@within(two=miliseconds) +def test_httpretty_should_allow_forcing_headers_urllib2(): + "HTTPretty should allow forcing headers with urllib2" + + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/", + body="this is supposed to be the response", + forcing_headers={ + 'Content-Type': 'application/xml', + 'Content-Length': '35a', + }) + + request = urlopen('http://github.com') + headers = dict(request.headers) + request.close() + + headers.should.equal({ + 'content-type': 'application/xml', + 'content-length': '35a', + }) + + +@httprettified +@freeze_time("2013-10-04 04:20:00") +def test_httpretty_should_allow_adding_and_overwritting_by_kwargs_u2(): + ("HTTPretty should allow adding and overwritting headers by " + "keyword args with urllib2") + + body = "this is supposed to be the response, indeed" + HTTPretty.register_uri(HTTPretty.GET, "http://github.com/", + body=body, + server='Apache', + content_length=len(body), + content_type='application/json') + + request = urlopen('http://github.com') + headers = dict(request.headers) + request.close() + + request.code.should.equal(200) + headers.should.equal({ + 'content-type': 'application/json', + 'connection': 'close', + 'content-length': str(len(body)), + 'status': '200', + 'server': 'Apache', + 'date': 'Fri, 04 Oct 2013 04:20:00 GMT', + }) + + +@httprettified +@within(two=miliseconds) +def test_httpretty_should_support_a_list_of_successive_responses_urllib2(now): + ("HTTPretty should support adding a list of successive " + "responses with urllib2") + + HTTPretty.register_uri( + HTTPretty.GET, "https://api.yahoo.com/test", + responses=[ + HTTPretty.Response(body="first response", status=201), + HTTPretty.Response(body='second and last response', status=202), + ]) + + request1 = urlopen('https://api.yahoo.com/test') + body1 = request1.read() + request1.close() + + request1.code.should.equal(201) + body1.should.equal(b'first response') + + request2 = urlopen('https://api.yahoo.com/test') + body2 = request2.read() + request2.close() + request2.code.should.equal(202) + body2.should.equal(b'second and last response') + + request3 = urlopen('https://api.yahoo.com/test') + body3 = request3.read() + request3.close() + request3.code.should.equal(202) + body3.should.equal(b'second and last response') + + +@httprettified +@within(two=miliseconds) +def test_can_inspect_last_request(now): + "HTTPretty.last_request is a mimetools.Message request from last match" + + HTTPretty.register_uri(HTTPretty.POST, "http://api.github.com/", + body='{"repositories": ["HTTPretty", "lettuce"]}') + + request = urllib2.Request( + 'http://api.github.com', + b'{"username": "gabrielfalcao"}', + { + 'content-type': 'text/json', + }, + ) + fd = urlopen(request) + got = fd.read() + fd.close() + + HTTPretty.last_request.method.should.equal('POST') + HTTPretty.last_request.body.should.equal( + b'{"username": "gabrielfalcao"}', + ) + HTTPretty.last_request.headers['content-type'].should.equal( + 'text/json', + ) + got.should.equal(b'{"repositories": ["HTTPretty", "lettuce"]}') + + +@httprettified +@within(two=miliseconds) +def test_can_inspect_last_request_with_ssl(now): + "HTTPretty.last_request is recorded even when mocking 'https' (SSL)" + + HTTPretty.register_uri(HTTPretty.POST, "https://secure.github.com/", + body='{"repositories": ["HTTPretty", "lettuce"]}') + + request = urllib2.Request( + 'https://secure.github.com', + b'{"username": "gabrielfalcao"}', + { + 'content-type': 'text/json', + }, + ) + fd = urlopen(request) + got = fd.read() + fd.close() + + HTTPretty.last_request.method.should.equal('POST') + HTTPretty.last_request.body.should.equal( + b'{"username": "gabrielfalcao"}', + ) + HTTPretty.last_request.headers['content-type'].should.equal( + 'text/json', + ) + got.should.equal(b'{"repositories": ["HTTPretty", "lettuce"]}') + + +@httprettified +@within(two=miliseconds) +def test_httpretty_ignores_querystrings_from_registered_uri(): + "HTTPretty should mock a simple GET with urllib2.read()" + + HTTPretty.register_uri(HTTPretty.GET, "http://yipit.com/?id=123", + body="Find the best daily deals") + + fd = urlopen('http://yipit.com/?id=123') + got = fd.read() + fd.close() + + got.should.equal(b'Find the best daily deals') + HTTPretty.last_request.method.should.equal('GET') + HTTPretty.last_request.path.should.equal('/?id=123') + + +@httprettified +@within(two=miliseconds) +def test_callback_response(now): + ("HTTPretty should call a callback function to be set as the body with" + " urllib2") + + def request_callback(request, uri, headers): + return [200, headers, "The {} response from {}".format(decode_utf8(request.method), uri)] + + HTTPretty.register_uri( + HTTPretty.GET, "https://api.yahoo.com/test", + body=request_callback) + + fd = urllib2.urlopen('https://api.yahoo.com/test') + got = fd.read() + fd.close() + + got.should.equal(b"The GET response from https://api.yahoo.com/test") + + HTTPretty.register_uri( + HTTPretty.POST, "https://api.yahoo.com/test_post", + body=request_callback) + + request = urllib2.Request( + "https://api.yahoo.com/test_post", + b'{"username": "gabrielfalcao"}', + { + 'content-type': 'text/json', + }, + ) + fd = urllib2.urlopen(request) + got = fd.read() + fd.close() + + got.should.equal(b"The POST response from https://api.yahoo.com/test_post") + + +@httprettified +def test_httpretty_should_allow_registering_regexes(): + "HTTPretty should allow registering regexes with urllib2" + + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r"https://api.yipit.com/v1/deal;brand=(?P\w+)"), + body="Found brand", + ) + + request = urllib2.Request( + "https://api.yipit.com/v1/deal;brand=GAP", + ) + fd = urllib2.urlopen(request) + got = fd.read() + fd.close() + + got.should.equal(b"Found brand") diff --git a/tests/functional/testserver.py b/tests/functional/testserver.py new file mode 100644 index 0000000..623dd88 --- /dev/null +++ b/tests/functional/testserver.py @@ -0,0 +1,162 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +from __future__ import unicode_literals + +import os +import time +import socket + +from tornado.web import Application +from tornado.web import RequestHandler +from tornado.httpserver import HTTPServer +from tornado.ioloop import IOLoop +from httpretty import HTTPretty +from httpretty.core import old_socket as true_socket +from multiprocessing import Process + + +def utf8(s): + if isinstance(s, str): + s = s.encode('utf-8') + + return bytes(s) + + +class BubblesHandler(RequestHandler): + def get(self): + self.write(". o O 0 O o . o O 0 O o . o O 0 O o . o O 0 O o . o O 0 O o .") + + +class ComeHandler(RequestHandler): + def get(self): + self.write("<- HELLO WORLD ->") + + +def subprocess_server_tornado(app, port, data={}): + from httpretty import HTTPretty + HTTPretty.disable() + + http = HTTPServer(app) + HTTPretty.disable() + + http.listen(int(port)) + IOLoop.instance().start() + + +class TornadoServer(object): + is_running = False + + def __init__(self, port): + self.port = int(port) + self.process = None + + @classmethod + def get_handlers(cls): + return Application([ + (r"/go-for-bubbles/?", BubblesHandler), + (r"/come-again/?", ComeHandler), + ]) + + def start(self): + + app = self.get_handlers() + + data = {} + args = (app, self.port, data) + HTTPretty.disable() + self.process = Process(target=subprocess_server_tornado, args=args) + self.process.start() + time.sleep(1) + + def stop(self): + try: + os.kill(self.process.pid, 9) + except OSError: + self.process.terminate() + finally: + self.is_running = False + + +def subprocess_server_tcp(port): + from httpretty import HTTPretty + HTTPretty.disable() + import socket + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(('localhost', port)) + s.listen(True) + conn, addr = s.accept() + + while True: + data = conn.recv(1024) + conn.send(b"RECEIVED: " + bytes(data)) + + conn.close() + + +class TCPServer(object): + def __init__(self, port): + self.port = int(port) + + def start(self): + HTTPretty.disable() + + + args = [self.port] + self.process = Process(target=subprocess_server_tcp, args=args) + self.process.start() + time.sleep(1) + + def stop(self): + try: + os.kill(self.process.pid, 9) + except OSError: + self.process.terminate() + finally: + self.is_running = False + + +class TCPClient(object): + def __init__(self, port): + self.port = int(port) + self.sock = true_socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect(('localhost', self.port)) + + def send(self, data): + if isinstance(data, str): + data = data.encode('utf-8') + + self.sock.sendall(data) + return self.sock.recv(len(data) + 11) + + def close(self): + try: + self.sock.close() + except socket.error: + pass # already closed + + def __del__(self): + self.close() diff --git a/tests/pyopenssl/__init__.py b/tests/pyopenssl/__init__.py new file mode 100644 index 0000000..1b809ba --- /dev/null +++ b/tests/pyopenssl/__init__.py @@ -0,0 +1,28 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. + +import warnings +warnings.simplefilter('ignore') diff --git a/tests/pyopenssl/test_mock.py b/tests/pyopenssl/test_mock.py new file mode 100644 index 0000000..f98b102 --- /dev/null +++ b/tests/pyopenssl/test_mock.py @@ -0,0 +1,45 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +from __future__ import unicode_literals + +import requests + +from httpretty import HTTPretty, httprettified +from sure import expect + + +@httprettified +def test_httpretty_overrides_when_pyopenssl_installed(): + ('HTTPretty should remove PyOpenSSLs urllib3 mock if it is installed') + # And HTTPretty works successfully + HTTPretty.register_uri(HTTPretty.GET, "https://yipit.com/", + body="Find the best daily deals") + + response = requests.get('https://yipit.com') + expect(response.text).to.equal('Find the best daily deals') + expect(HTTPretty.last_request.method).to.equal('GET') + expect(HTTPretty.last_request.path).to.equal('/') diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..a9eab2a --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,25 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py new file mode 100644 index 0000000..80c4a86 --- /dev/null +++ b/tests/unit/test_core.py @@ -0,0 +1,668 @@ +import io +import json +import errno + +from freezegun import freeze_time +from sure import expect + +from httpretty.core import HTTPrettyRequest, FakeSSLSocket, fakesock, httpretty +from httpretty.core import URIMatcher, URIInfo + +from tests.compat import Mock, patch, call + + +class SocketErrorStub(Exception): + def __init__(self, errno): + self.errno = errno + + +def test_request_stubs_internals(): + ("HTTPrettyRequest is a BaseHTTPRequestHandler that replaces " + "real socket file descriptors with in-memory ones") + + # Given a valid HTTP request header string + headers = "\r\n".join([ + 'POST /somewhere/?name=foo&age=bar HTTP/1.1', + 'accept-encoding: identity', + 'host: github.com', + 'content-type: application/json', + 'connection: close', + 'user-agent: Python-urllib/2.7', + ]) + + # When I create a HTTPrettyRequest with an empty body + request = HTTPrettyRequest(headers, body='') + + # Then it should have parsed the headers + dict(request.headers).should.equal({ + 'accept-encoding': 'identity', + 'connection': 'close', + 'content-type': 'application/json', + 'host': 'github.com', + 'user-agent': 'Python-urllib/2.7' + }) + + # And the `rfile` should be a io.BytesIO + type_as_str = io.BytesIO.__module__ + '.' + io.BytesIO.__name__ + + request.should.have.property('rfile').being.a(type_as_str) + + # And the `wfile` should be a io.BytesIO + request.should.have.property('wfile').being.a(type_as_str) + + # And the `method` should be available + request.should.have.property('method').being.equal('POST') + + +def test_request_parse_querystring(): + ("HTTPrettyRequest#parse_querystring should parse unicode data") + + # Given a request string containing a unicode encoded querystring + + headers = "\r\n".join([ + 'POST /create?name=Gabriel+Falcão HTTP/1.1', + 'Content-Type: multipart/form-data', + ]) + + # When I create a HTTPrettyRequest with an empty body + request = HTTPrettyRequest(headers, body='') + + # Then it should have a parsed querystring + request.querystring.should.equal({'name': ['Gabriel Falcão']}) + + +def test_request_parse_body_when_it_is_application_json(): + ("HTTPrettyRequest#parse_request_body recognizes the " + "content-type `application/json` and parses it") + + # Given a request string containing a unicode encoded querystring + headers = "\r\n".join([ + 'POST /create HTTP/1.1', + 'Content-Type: application/json', + ]) + # And a valid json body + body = json.dumps({'name': 'Gabriel Falcão'}) + + # When I create a HTTPrettyRequest with that data + request = HTTPrettyRequest(headers, body) + + # Then it should have a parsed body + request.parsed_body.should.equal({'name': 'Gabriel Falcão'}) + + +def test_request_parse_body_when_it_is_text_json(): + ("HTTPrettyRequest#parse_request_body recognizes the " + "content-type `text/json` and parses it") + + # Given a request string containing a unicode encoded querystring + headers = "\r\n".join([ + 'POST /create HTTP/1.1', + 'Content-Type: text/json', + ]) + # And a valid json body + body = json.dumps({'name': 'Gabriel Falcão'}) + + # When I create a HTTPrettyRequest with that data + request = HTTPrettyRequest(headers, body) + + # Then it should have a parsed body + request.parsed_body.should.equal({'name': 'Gabriel Falcão'}) + + +def test_request_parse_body_when_it_is_urlencoded(): + ("HTTPrettyRequest#parse_request_body recognizes the " + "content-type `application/x-www-form-urlencoded` and parses it") + + # Given a request string containing a unicode encoded querystring + headers = "\r\n".join([ + 'POST /create HTTP/1.1', + 'Content-Type: application/x-www-form-urlencoded', + ]) + # And a valid urlencoded body + body = "name=Gabriel+Falcão&age=25&projects=httpretty&projects=sure&projects=lettuce" + + # When I create a HTTPrettyRequest with that data + request = HTTPrettyRequest(headers, body) + + # Then it should have a parsed body + request.parsed_body.should.equal({ + 'name': ['Gabriel Falcão'], + 'age': ["25"], + 'projects': ["httpretty", "sure", "lettuce"] + }) + + +def test_request_parse_body_when_unrecognized(): + ("HTTPrettyRequest#parse_request_body returns the value as " + "is if the Content-Type is not recognized") + + # Given a request string containing a unicode encoded querystring + headers = "\r\n".join([ + 'POST /create HTTP/1.1', + 'Content-Type: whatever', + ]) + # And a valid urlencoded body + body = "foobar:\nlalala" + + # When I create a HTTPrettyRequest with that data + request = HTTPrettyRequest(headers, body) + + # Then it should have a parsed body + request.parsed_body.should.equal("foobar:\nlalala") + + +def test_request_string_representation(): + ("HTTPrettyRequest should have a forward_and_trace-friendly " + "string representation") + + # Given a request string containing a unicode encoded querystring + headers = "\r\n".join([ + 'POST /create HTTP/1.1', + 'Content-Type: JPEG-baby', + 'Host: blog.falcao.it' + ]) + # And a valid urlencoded body + body = "foobar:\nlalala" + + # When I create a HTTPrettyRequest with that data + request = HTTPrettyRequest(headers, body, sock=Mock(is_https=True)) + + # Then its string representation should show the headers and the body + str(request).should.equal('') + + +def test_fake_ssl_socket_proxies_its_ow_socket(): + ("FakeSSLSocket is a simpel wrapper around its own socket, " + "which was designed to be a HTTPretty fake socket") + + # Given a sentinel mock object + socket = Mock() + + # And a FakeSSLSocket wrapping it + ssl = FakeSSLSocket(socket) + + # When I make a method call + ssl.send("FOO") + + # Then it should bypass any method calls to its own socket + socket.send.assert_called_once_with("FOO") + + +@freeze_time("2013-10-04 04:20:00") +def test_fakesock_socket_getpeercert(): + ("fakesock.socket#getpeercert should return a hardcoded fake certificate") + # Given a fake socket instance + socket = fakesock.socket() + + # And that it's bound to some host + socket._host = 'somewhere.com' + + # When I retrieve the peer certificate + certificate = socket.getpeercert() + + # Then it should return a hardcoded value + certificate.should.equal({ + u'notAfter': 'Sep 29 04:20:00 GMT', + u'subject': ( + ((u'organizationName', u'*.somewhere.com'),), + ((u'organizationalUnitName', u'Domain Control Validated'),), + ((u'commonName', u'*.somewhere.com'),)), + u'subjectAltName': ( + (u'DNS', u'*.somewhere.com'), + (u'DNS', u'somewhere.com'), + (u'DNS', u'*') + ) + }) + + +def test_fakesock_socket_ssl(): + ("fakesock.socket#ssl should take a socket instance and return itself") + # Given a fake socket instance + socket = fakesock.socket() + + # And a stubbed socket sentinel + sentinel = Mock() + + # When I call `ssl` on that mock + result = socket.ssl(sentinel) + + # Then it should have returned its first argument + result.should.equal(sentinel) + + +@patch('httpretty.core.old_socket') +@patch('httpretty.core.POTENTIAL_HTTP_PORTS') +def test_fakesock_socket_connect_fallback(POTENTIAL_HTTP_PORTS, old_socket): + ("fakesock.socket#connect should open a real connection if the " + "given port is not a potential http port") + # Background: the potential http ports are 80 and 443 + POTENTIAL_HTTP_PORTS.__contains__.side_effect = lambda other: int(other) in (80, 443) + + # Given a fake socket instance + socket = fakesock.socket() + + # When it is connected to a remote server in a port that isn't 80 nor 443 + socket.connect(('somewhere.com', 42)) + + # Then it should have open a real connection in the background + old_socket.return_value.connect.assert_called_once_with(('somewhere.com', 42)) + + +@patch('httpretty.core.old_socket') +def test_fakesock_socket_close(old_socket): + ("fakesock.socket#close should close the actual socket in case " + "it's not http and __truesock_is_connected__ is True") + # Given a fake socket instance that is synthetically open + socket = fakesock.socket() + socket.__truesock_is_connected__ = True + + # When I close it + socket.close() + + # Then its real socket should have been closed + old_socket.return_value.close.assert_called_once_with() + socket.__truesock_is_connected__.should.be.false + + +@patch('httpretty.core.old_socket') +def test_fakesock_socket_makefile(old_socket): + ("fakesock.socket#makefile should set the mode, " + "bufsize and return its mocked file descriptor") + + # Given a fake socket that has a mocked Entry associated with it + socket = fakesock.socket() + socket._entry = Mock() + + # When I call makefile() + fd = socket.makefile(mode='rw', bufsize=512) + + # Then it should have returned the socket's own filedescriptor + expect(fd).to.equal(socket.fd) + # And the mode should have been set in the socket instance + socket._mode.should.equal('rw') + # And the bufsize should have been set in the socket instance + socket._bufsize.should.equal(512) + + # And the entry should have been filled with that filedescriptor + socket._entry.fill_filekind.assert_called_once_with(fd) + + +@patch('httpretty.core.old_socket') +def test_fakesock_socket_real_sendall(old_socket): + ("fakesock.socket#real_sendall calls truesock#connect and bails " + "out when not http") + # Background: the real socket will stop returning bytes after the + # first call + real_socket = old_socket.return_value + real_socket.recv.side_effect = [b'response from server', b""] + + # Given a fake socket + socket = fakesock.socket() + socket._address = ('1.2.3.4', 42) + + # When I call real_sendall with data, some args and kwargs + socket.real_sendall(b"SOMEDATA", b'some extra args...', foo=b'bar') + + # Then it should have called sendall in the real socket + real_socket.sendall.assert_called_once_with(b"SOMEDATA", b'some extra args...', foo=b'bar') + + # # And setblocking was never called + # real_socket.setblocking.called.should.be.false + + # And recv was never called + real_socket.recv.called.should.be.false + + # And the buffer is empty + socket.fd.read().should.equal(b'') + + # And connect was never called + real_socket.connect.called.should.be.false + + +@patch('httpretty.core.old_socket') +def test_fakesock_socket_real_sendall_when_http(old_socket): + ("fakesock.socket#real_sendall sends data and buffers " + "the response in the file descriptor") + # Background: the real socket will stop returning bytes after the + # first call + real_socket = old_socket.return_value + real_socket.recv.side_effect = [b'response from server', b""] + + # Given a fake socket + socket = fakesock.socket() + socket._address = ('1.2.3.4', 42) + socket.is_http = True + + # When I call real_sendall with data, some args and kwargs + socket.real_sendall(b"SOMEDATA", b'some extra args...', foo=b'bar') + + # Then it should have called sendall in the real socket + real_socket.sendall.assert_called_once_with(b"SOMEDATA", b'some extra args...', foo=b'bar') + + # And the socket was set to blocking + real_socket.setblocking.assert_called_once_with(1) + + # And recv was called with the bufsize + real_socket.recv.assert_has_calls([ + call(socket._bufsize) + ]) + + # And the buffer should contain the data from the server + socket.fd.read().should.equal(b"response from server") + + # And connect was called + real_socket.connect.called.should.be.true + + +@patch('httpretty.core.old_socket') +@patch('httpretty.core.socket') +def test_fakesock_socket_real_sendall_continue_eagain_when_http(socket, old_socket): + ("fakesock.socket#real_sendall should continue if the socket error was EAGAIN") + socket.error = SocketErrorStub + # Background: the real socket will stop returning bytes after the + # first call + real_socket = old_socket.return_value + real_socket.recv.side_effect = [SocketErrorStub(errno.EAGAIN), b'after error', b""] + + # Given a fake socket + socket = fakesock.socket() + socket._address = ('1.2.3.4', 42) + socket.is_http = True + + # When I call real_sendall with data, some args and kwargs + socket.real_sendall(b"SOMEDATA", b'some extra args...', foo=b'bar') + + # Then it should have called sendall in the real socket + real_socket.sendall.assert_called_once_with(b"SOMEDATA", b'some extra args...', foo=b'bar') + + # And the socket was set to blocking + real_socket.setblocking.assert_called_once_with(1) + + # And recv was called with the bufsize + real_socket.recv.assert_has_calls([ + call(socket._bufsize) + ]) + + # And the buffer should contain the data from the server + socket.fd.read().should.equal(b"after error") + + # And connect was called + real_socket.connect.called.should.be.true + + +@patch('httpretty.core.old_socket') +@patch('httpretty.core.socket') +def test_fakesock_socket_real_sendall_socket_error_when_http(socket, old_socket): + ("fakesock.socket#real_sendall should continue if the socket error was EAGAIN") + socket.error = SocketErrorStub + # Background: the real socket will stop returning bytes after the + # first call + real_socket = old_socket.return_value + real_socket.recv.side_effect = [SocketErrorStub(42), b'after error', ""] + + # Given a fake socket + socket = fakesock.socket() + socket._address = ('1.2.3.4', 42) + socket.is_http = True + + # When I call real_sendall with data, some args and kwargs + socket.real_sendall(b"SOMEDATA", b'some extra args...', foo=b'bar') + + # Then it should have called sendall in the real socket + real_socket.sendall.assert_called_once_with(b"SOMEDATA", b'some extra args...', foo=b'bar') + + # And the socket was set to blocking + real_socket.setblocking.assert_called_once_with(1) + + # And recv was called with the bufsize + real_socket.recv.assert_called_once_with(socket._bufsize) + + # And the buffer should contain the data from the server + socket.fd.read().should.equal(b"") + + # And connect was called + real_socket.connect.called.should.be.true + + +@patch('httpretty.core.old_socket') +@patch('httpretty.core.POTENTIAL_HTTP_PORTS') +def test_fakesock_socket_real_sendall_when_sending_data(POTENTIAL_HTTP_PORTS, old_socket): + ("fakesock.socket#real_sendall should connect before sending data") + # Background: the real socket will stop returning bytes after the + # first call + real_socket = old_socket.return_value + real_socket.recv.side_effect = [b'response from foobar :)', b""] + + # And the potential http port is 4000 + POTENTIAL_HTTP_PORTS.__contains__.side_effect = lambda other: int(other) == 4000 + POTENTIAL_HTTP_PORTS.union.side_effect = lambda other: POTENTIAL_HTTP_PORTS + + # Given a fake socket + socket = fakesock.socket() + + # When I call connect to a server in a port that is considered HTTP + socket.connect(('foobar.com', 4000)) + + # And send some data + socket.real_sendall(b"SOMEDATA") + + # Then connect should have been called + real_socket.connect.assert_called_once_with(('foobar.com', 4000)) + + # And the socket was set to blocking + real_socket.setblocking.assert_called_once_with(1) + + # And recv was called with the bufsize + real_socket.recv.assert_has_calls([ + call(socket._bufsize) + ]) + + # And the buffer should contain the data from the server + socket.fd.read().should.equal(b"response from foobar :)") + + +@patch('httpretty.core.old_socket') +@patch('httpretty.core.httpretty') +@patch('httpretty.core.POTENTIAL_HTTP_PORTS') +def test_fakesock_socket_sendall_with_valid_requestline(POTENTIAL_HTTP_PORTS, httpretty, old_socket): + ("fakesock.socket#sendall should create an entry if it's given a valid request line") + matcher = Mock(name='matcher') + info = Mock(name='info') + httpretty.match_uriinfo.return_value = (matcher, info) + httpretty.register_uri(httpretty.GET, 'http://foo.com/foobar') + + # Background: + # using a subclass of socket that mocks out real_sendall + class MySocket(fakesock.socket): + def real_sendall(self, data, *args, **kw): + raise AssertionError('should never call this...') + + # Given an instance of that socket + socket = MySocket() + + # And that is is considered http + socket.connect(('foo.com', 80)) + + # When I try to send data + socket.sendall(b"GET /foobar HTTP/1.1\r\nContent-Type: application/json\r\n\r\n") + + +@patch('httpretty.core.old_socket') +@patch('httpretty.core.httpretty') +@patch('httpretty.core.POTENTIAL_HTTP_PORTS') +def test_fakesock_socket_sendall_with_valid_requestline_2(POTENTIAL_HTTP_PORTS, httpretty, old_socket): + ("fakesock.socket#sendall should create an entry if it's given a valid request line") + matcher = Mock(name='matcher') + info = Mock(name='info') + httpretty.match_uriinfo.return_value = (matcher, info) + httpretty.register_uri(httpretty.GET, 'http://foo.com/foobar') + + # Background: + # using a subclass of socket that mocks out real_sendall + class MySocket(fakesock.socket): + def real_sendall(self, data, *args, **kw): + raise AssertionError('should never call this...') + + # Given an instance of that socket + socket = MySocket() + + # And that is is considered http + socket.connect(('foo.com', 80)) + + # When I try to send data + socket.sendall(b"GET /foobar HTTP/1.1\r\nContent-Type: application/json\r\n\r\n") + + +@patch('httpretty.core.old_socket') +def test_fakesock_socket_sendall_with_body_data_no_entry(old_socket): + ("fakesock.socket#sendall should call real_sendall when not parsing headers and there is no entry") + # Background: + # Using a subclass of socket that mocks out real_sendall + + class MySocket(fakesock.socket): + def real_sendall(self, data): + data.should.equal(b'BLABLABLABLA') + return 'cool' + + # Given an instance of that socket + socket = MySocket() + socket._entry = None + + # And that is is considered http + socket.connect(('foo.com', 80)) + + # When I try to send data + result = socket.sendall(b"BLABLABLABLA") + + # Then the result should be the return value from real_sendall + result.should.equal('cool') + + +@patch('httpretty.core.old_socket') +@patch('httpretty.core.POTENTIAL_HTTP_PORTS') +def test_fakesock_socket_sendall_with_body_data_with_entry(POTENTIAL_HTTP_PORTS, old_socket): + ("fakesock.socket#sendall should call real_sendall when there is no entry") + # Background: + # Using a subclass of socket that mocks out real_sendall + data_sent = [] + + class MySocket(fakesock.socket): + def real_sendall(self, data): + data_sent.append(data) + + # Given an instance of that socket + socket = MySocket() + + # And that is is considered http + socket.connect(('foo.com', 80)) + + # When I try to send data + socket.sendall(b"BLABLABLABLA") + + # Then it should have called real_sendall + data_sent.should.equal([b'BLABLABLABLA']) + + +@patch('httpretty.core.httpretty.match_uriinfo') +@patch('httpretty.core.old_socket') +@patch('httpretty.core.POTENTIAL_HTTP_PORTS') +def test_fakesock_socket_sendall_with_body_data_with_chunked_entry(POTENTIAL_HTTP_PORTS, old_socket, match_uriinfo): + ("fakesock.socket#sendall should call real_sendall when not ") + # Background: + # Using a subclass of socket that mocks out real_sendall + + class MySocket(fakesock.socket): + def real_sendall(self, data): + raise AssertionError('should have never been called') + + matcher = Mock(name='matcher') + info = Mock(name='info') + httpretty.match_uriinfo.return_value = (matcher, info) + + # Using a mocked entry + entry = Mock() + entry.method = 'GET' + entry.info.path = '/foo' + + entry.request.headers = { + 'transfer-encoding': 'chunked', + } + entry.request.body = b'' + + # Given an instance of that socket + socket = MySocket() + socket._entry = entry + + # And that is is considered http + socket.connect(('foo.com', 80)) + + # When I try to send data + socket.sendall(b"BLABLABLABLA") + + # Then the entry should have that body + httpretty.last_request.body.should.equal(b'BLABLABLABLA') + + +def test_fakesock_socket_sendall_with_path_starting_with_two_slashes(): + ("fakesock.socket#sendall handles paths starting with // well") + + httpretty.register_uri(httpretty.GET, 'http://example.com//foo') + + class MySocket(fakesock.socket): + def real_sendall(self, data, *args, **kw): + raise AssertionError('should never call this...') + + # Given an instance of that socket + socket = MySocket() + + # And that is is considered http + socket.connect(('example.com', 80)) + + # When I try to send data + socket.sendall(b"GET //foo HTTP/1.1\r\nContent-Type: application/json\r\n\r\n") + + +def test_URIMatcher_respects_querystring(): + ("URIMatcher response querystring") + matcher = URIMatcher('http://www.foo.com/?query=true', None) + info = URIInfo.from_uri('http://www.foo.com/', None) + assert matcher.matches(info) + + matcher = URIMatcher('http://www.foo.com/?query=true', None, match_querystring=True) + info = URIInfo.from_uri('http://www.foo.com/', None) + assert not matcher.matches(info) + + matcher = URIMatcher('http://www.foo.com/?query=true', None, match_querystring=True) + info = URIInfo.from_uri('http://www.foo.com/?query=true', None) + assert matcher.matches(info) + + matcher = URIMatcher('http://www.foo.com/?query=true&unquery=false', None, match_querystring=True) + info = URIInfo.from_uri('http://www.foo.com/?unquery=false&query=true', None) + assert matcher.matches(info) + + matcher = URIMatcher('http://www.foo.com/?unquery=false&query=true', None, match_querystring=True) + info = URIInfo.from_uri('http://www.foo.com/?query=true&unquery=false', None) + assert matcher.matches(info) + + +def test_URIMatcher_equality_respects_querystring(): + ("URIMatcher equality check should check querystring") + matcher_a = URIMatcher('http://www.foo.com/?query=true', None) + matcher_b = URIMatcher('http://www.foo.com/?query=false', None) + assert matcher_a == matcher_b + + matcher_a = URIMatcher('http://www.foo.com/?query=true', None) + matcher_b = URIMatcher('http://www.foo.com/', None) + assert matcher_a == matcher_b + + matcher_a = URIMatcher('http://www.foo.com/?query=true', None, match_querystring=True) + matcher_b = URIMatcher('http://www.foo.com/?query=false', None, match_querystring=True) + assert not matcher_a == matcher_b + + matcher_a = URIMatcher('http://www.foo.com/?query=true', None, match_querystring=True) + matcher_b = URIMatcher('http://www.foo.com/', None, match_querystring=True) + assert not matcher_a == matcher_b + + matcher_a = URIMatcher('http://www.foo.com/?query=true&unquery=false', None, match_querystring=True) + matcher_b = URIMatcher('http://www.foo.com/?unquery=false&query=true', None, match_querystring=True) + assert matcher_a == matcher_b diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py new file mode 100644 index 0000000..7ea5fe1 --- /dev/null +++ b/tests/unit/test_http.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from httpretty.http import parse_requestline + + +def test_parse_request_line_connect(): + ("parse_requestline should parse the CONNECT method appropriately") + + # Given a valid request line string that has the CONNECT method + line = "CONNECT / HTTP/1.1" + + # When I parse it + result = parse_requestline(line) + + # Then it should return a tuple + result.should.equal(("CONNECT", "/", "1.1")) diff --git a/tests/unit/test_httpretty.py b/tests/unit/test_httpretty.py new file mode 100644 index 0000000..017b290 --- /dev/null +++ b/tests/unit/test_httpretty.py @@ -0,0 +1,429 @@ +# #!/usr/bin/env python +# -*- coding: utf-8 -*- + +# +# Copyright (C) <2011-2021> Gabriel Falcão +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +from __future__ import unicode_literals +import re +import json +from sure import expect +import httpretty +from httpretty import HTTPretty +from httpretty import HTTPrettyError +from httpretty import core +from httpretty.core import URIInfo, BaseClass, Entry, FakeSockFile, HTTPrettyRequest +from httpretty.http import STATUSES + +from tests.compat import MagicMock, patch + + +TEST_HEADER = """ +GET /test/test.html HTTP/1.1 +Host: www.host1.com:80 +Content-Type: %(content_type)s +""" + + +def test_httpretty_should_raise_proper_exception_on_inconsistent_length(): + ("HTTPretty should raise proper exception on inconsistent Content-Length / " + "registered response body") + + HTTPretty.register_uri.when.called_with( + HTTPretty.GET, + "http://github.com/gabrielfalcao", + body="that's me!", + adding_headers={ + 'Content-Length': '999' + } + ).should.have.raised( + HTTPrettyError, + 'HTTPretty got inconsistent parameters. The header Content-Length you registered expects size "999" ' + 'but the body you registered for that has actually length "10".' + ) + + +def test_does_not_have_last_request_by_default(): + 'HTTPretty.last_request is a dummy object by default' + HTTPretty.reset() + + expect(HTTPretty.last_request.headers).to.be.empty + expect(HTTPretty.last_request.body).to.be.empty + + +def test_status_codes(): + "HTTPretty supports N status codes" + + expect(STATUSES).to.equal({ + 100: "Continue", + 101: "Switching Protocols", + 102: "Processing", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non-Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 208: "Already Reported", + 226: "IM Used", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Found", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 306: "Switch Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request a Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Request Entity Too Large", + 414: "Request-URI Too Long", + 415: "Unsupported Media Type", + 416: "Requested Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 420: "Enhance Your Calm", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 425: "Unordered Collection", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 444: "No Response", + 449: "Retry With", + 450: "Blocked by Windows Parental Controls", + 451: "Unavailable For Legal Reasons", + 494: "Request Header Too Large", + 495: "Cert Error", + 496: "No Cert", + 497: "HTTP to HTTPS", + 499: "Client Closed Request", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 509: "Bandwidth Limit Exceeded", + 510: "Not Extended", + 511: "Network Authentication Required", + 598: "Network read timeout error", + 599: "Network connect timeout error", + }) + + +def test_uri_info_full_url(): + uri_info = URIInfo( + username='johhny', + password='password', + hostname=b'google.com', + port=80, + path=b'/', + query=b'foo=bar&baz=test', + fragment='', + scheme='', + ) + + expect(uri_info.full_url()).to.equal( + "http://johhny:password@google.com/?baz=test&foo=bar" + ) + + expect(uri_info.full_url(use_querystring=False)).to.equal( + "http://johhny:password@google.com/" + ) + + +def test_uri_info_eq_ignores_case(): + """Test that URIInfo.__eq__ method ignores case for + hostname matching. + """ + uri_info_uppercase = URIInfo( + username='johhny', + password='password', + hostname=b'GOOGLE.COM', + port=80, + path=b'/', + query=b'foo=bar&baz=test', + fragment='', + scheme='', + ) + uri_info_lowercase = URIInfo( + username='johhny', + password='password', + hostname=b'google.com', + port=80, + path=b'/', + query=b'foo=bar&baz=test', + fragment='', + scheme='', + ) + expect(uri_info_uppercase).to.equal(uri_info_lowercase) + + +def test_global_boolean_enabled(): + HTTPretty.disable() + expect(HTTPretty.is_enabled()).to.be.falsy + HTTPretty.enable() + expect(HTTPretty.is_enabled()).to.be.truthy + HTTPretty.disable() + expect(HTTPretty.is_enabled()).to.be.falsy + + +def test_py3kobject_implements_valid__repr__based_on__str__(): + class MyObject(BaseClass): + def __str__(self): + return 'hi' + + myobj = MyObject() + expect(repr(myobj)).to.be.equal('hi') + + +def test_Entry_class_normalizes_headers(): + entry = Entry(HTTPretty.GET, 'http://example.com', 'example', + host='example.com', cache_control='no-cache', x_forward_for='proxy') + + entry.adding_headers.should.equal({ + 'Host': 'example.com', + 'Cache-Control': 'no-cache', + 'X-Forward-For': 'proxy' + }) + + +def test_Entry_class_counts_multibyte_characters_in_bytes(): + entry = Entry(HTTPretty.GET, 'http://example.com', 'こんにちは') + buf = FakeSockFile() + entry.fill_filekind(buf) + response = buf.read() + expect(b'content-length: 15\n').to.be.within(response) + + +def test_Entry_class_counts_dynamic(): + result = (200, {}, 'こんにちは'.encode('utf-8')) + entry = Entry(HTTPretty.GET, 'http://example.com', lambda *args: result) + entry.info = URIInfo.from_uri('http://example.com', entry) + buf = FakeSockFile() + entry.fill_filekind(buf) + response = buf.getvalue() + expect(b'content-length: 15\n').to.be.within(response) + + +def test_fake_socket_passes_through_setblocking(): + import socket + HTTPretty.enable() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.setblocking).called_with(0).should_not.throw(AttributeError) + s.truesock.setblocking.assert_called_with(0) + + +def test_fake_socket_passes_through_fileno(): + import socket + with httpretty.enabled(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.fileno).called_with().should_not.throw(AttributeError) + s.truesock.fileno.assert_called_with() + + +def test_fake_socket_passes_through_getsockopt(): + import socket + HTTPretty.enable() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.getsockopt).called_with(socket.SOL_SOCKET, 1).should_not.throw(AttributeError) + s.truesock.getsockopt.assert_called_with(socket.SOL_SOCKET, 1) + + +def test_fake_socket_passes_through_bind(): + import socket + HTTPretty.enable() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.bind).called_with(('127.0.0.1', 1000)).should_not.throw(AttributeError) + s.truesock.bind.assert_called_with(('127.0.0.1', 1000)) + + +def test_fake_socket_passes_through_connect_ex(): + import socket + HTTPretty.enable() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.connect_ex).called_with().should_not.throw(AttributeError) + s.truesock.connect_ex.assert_called_with() + + +def test_fake_socket_passes_through_listen(): + import socket + HTTPretty.enable() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.listen).called_with().should_not.throw(AttributeError) + s.truesock.listen.assert_called_with() + + +def test_fake_socket_passes_through_getpeername(): + import socket + HTTPretty.enable() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.getpeername).called_with().should_not.throw(AttributeError) + s.truesock.getpeername.assert_called_with() + + +def test_fake_socket_passes_through_getsockname(): + import socket + HTTPretty.enable() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.getsockname).called_with().should_not.throw(AttributeError) + s.truesock.getsockname.assert_called_with() + + +def test_fake_socket_passes_through_gettimeout(): + import socket + HTTPretty.enable() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.gettimeout).called_with().should_not.throw(AttributeError) + s.truesock.gettimeout.assert_called_with() + + +def test_fake_socket_passes_through_shutdown(): + import socket + HTTPretty.enable() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.truesock = MagicMock() + expect(s.shutdown).called_with(socket.SHUT_RD).should_not.throw(AttributeError) + s.truesock.shutdown.assert_called_with(socket.SHUT_RD) + + +def test_unix_socket(): + import socket + HTTPretty.enable() + + # Create a UDS socket + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server_address = './not-exist-socket' + try: + sock.connect(server_address) + except socket.error: + # We expect this, since the server_address does not exist + pass + + +def test_HTTPrettyRequest_json_body(): + """ A content-type of application/json should parse a valid json body """ + header = TEST_HEADER % {'content_type': 'application/json'} + test_dict = {'hello': 'world'} + request = HTTPrettyRequest(header, json.dumps(test_dict)) + expect(request.parsed_body).to.equal(test_dict) + + +def test_HTTPrettyRequest_invalid_json_body(): + """ A content-type of application/json with an invalid json body should return the content unaltered """ + header = TEST_HEADER % {'content_type': 'application/json'} + invalid_json = u"{'hello', 'world','thisstringdoesntstops}" + request = HTTPrettyRequest(header, invalid_json) + expect(request.parsed_body).to.equal(invalid_json) + + +def test_HTTPrettyRequest_queryparam(): + """ A content-type of x-www-form-urlencoded with a valid queryparam body should return parsed content """ + header = TEST_HEADER % {'content_type': 'application/x-www-form-urlencoded'} + valid_queryparam = u"hello=world&this=isavalidquerystring" + valid_results = {'hello': ['world'], 'this': ['isavalidquerystring']} + request = HTTPrettyRequest(header, valid_queryparam) + expect(request.parsed_body).to.equal(valid_results) + + +def test_HTTPrettyRequest_arbitrarypost(): + """ A non-handled content type request's post body should return the content unaltered """ + header = TEST_HEADER % {'content_type': 'thisis/notarealcontenttype'} + gibberish_body = "1234567890!@#$%^&*()" + request = HTTPrettyRequest(header, gibberish_body) + expect(request.parsed_body).to.equal(gibberish_body) + + +def test_socktype_bad_python_version_regression(): + """ Some versions of python accidentally internally shadowed the SockType + variable, so it was no longer the socket object but and int Enum representing + the socket type e.g. AF_INET. Make sure we don't patch SockType in these cases + https://bugs.python.org/issue20386 + """ + import socket + someObject = object() + with patch('socket.SocketType', someObject): + HTTPretty.enable() + expect(socket.SocketType).to.equal(someObject) + HTTPretty.disable() + + +def test_socktype_good_python_version(): + import socket + with patch('socket.SocketType', socket.socket): + HTTPretty.enable() + expect(socket.SocketType).to.equal(socket.socket) + HTTPretty.disable() + + +def test_httpretty_should_allow_registering_regex_hostnames(): + "HTTPretty should allow registering regexes with requests" + + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r'^http://\w+\.foo\.com/baz$'), + body="yay", + ) + + assert HTTPretty.match_http_address('www.foo.com', 80) + + +def test_httpretty_should_allow_registering_regex_hostnames_ssl(): + "HTTPretty should allow registering regexes with requests (ssl version)" + + HTTPretty.register_uri( + HTTPretty.GET, + re.compile(r'^https://\w+\.foo\.com/baz$'), + body="yay", + ) + + assert HTTPretty.match_https_hostname('www.foo.com') diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py new file mode 100644 index 0000000..35a7b7d --- /dev/null +++ b/tests/unit/test_main.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +import httpretty +from httpretty.core import HTTPrettyRequest + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +@patch('httpretty.httpretty') +def test_last_request(original): + ("httpretty.last_request() should return httpretty.core.last_request") + + httpretty.last_request().should.equal(original.last_request) + + +@patch('httpretty.httpretty') +def test_latest_requests(original): + ("httpretty.latest_requests() should return httpretty.core.latest_requests") + + httpretty.latest_requests().should.equal(original.latest_requests) + + +def test_has_request(): + ("httpretty.has_request() correctly detects " + "whether or not a request has been made") + httpretty.reset() + httpretty.has_request().should.be.false + with patch('httpretty.httpretty.last_request', return_value=HTTPrettyRequest('')): + httpretty.has_request().should.be.true diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..def2c55 --- /dev/null +++ b/tox.ini @@ -0,0 +1,7 @@ +[tox] +envlist = py27, py36, py37 + +[testenv] +deps = pipenv + +commands = make test