diff options
author | Gabriel Falcão <gabrielfalcao@users.noreply.github.com> | 2021-05-13 02:02:36 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-13 02:02:36 +0200 |
commit | b6161cebfdaf5f68ef49526e51b460b8e0c0c6c2 (patch) | |
tree | 698ce62d3455dc0e5631a99e111807a417d69cdd | |
parent | 3528d0d8ea29e5aaff0341b1b3cfe986031bad31 (diff) | |
download | httpretty-b6161cebfdaf5f68ef49526e51b460b8e0c0c6c2.tar.gz |
Improve debugging experience via `UnmockedError` and logging (#419)
Feature: Display mismatched URL within UnmockedError whenever possible. Closes #388
Feature: Display mismatched URL via logging. #419
Add new properties to `httpretty.core.HTTPrettyRequest` (protocol, host, url, path, method).
-rw-r--r-- | Makefile | 10 | ||||
-rw-r--r-- | development.txt | 4 | ||||
-rw-r--r-- | docs/Makefile | 7 | ||||
-rw-r--r-- | docs/source/acks.rst | 4 | ||||
-rw-r--r-- | docs/source/api.rst | 18 | ||||
-rw-r--r-- | docs/source/changelog.rst | 20 | ||||
-rw-r--r-- | docs/source/introduction.rst | 41 | ||||
-rw-r--r-- | httpretty/core.py | 199 | ||||
-rw-r--r-- | httpretty/errors.py | 21 | ||||
-rw-r--r-- | tests/functional/bugfixes/test_242_ssl_bad_handshake.py | 9 | ||||
-rw-r--r-- | tests/functional/bugfixes/test_388_unmocked_error_with_url.py | 56 | ||||
-rw-r--r-- | tests/functional/test_bypass.py | 6 | ||||
-rw-r--r-- | tests/functional/test_passthrough.py | 9 | ||||
-rw-r--r-- | tests/functional/test_requests.py | 22 | ||||
-rw-r--r-- | tests/unit/test_core.py | 6 |
15 files changed, 319 insertions, 113 deletions
@@ -1,4 +1,4 @@ -.PHONY: tests all unit functional clean dependencies tdd docs html purge dist +.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 @@ -17,7 +17,7 @@ $(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/python $(VENV)/bin/pip: # installs latest pip +setup $(VENV)/bin/sphinx-build $(VENV)/bin/twine $(VENV)/bin/nosetests $(VENV)/bin/python $(VENV)/bin/pip: # installs latest pip test -e $(VENV)/bin/pip || make $(VENV) $(VENV)/bin/pip install -r development.txt $(VENV)/bin/pip install -e . @@ -49,12 +49,12 @@ functional: $(VENV)/bin/nosetests # runs functional tests -$(DOCS_INDEX): | $(VENV)/bin/sphinx-build +$(DOCS_INDEX): $(VENV)/bin/sphinx-build cd docs && make html -html: $(DOCS_INDEX) +html: $(DOCS_INDEX) $(VENV)/bin/sphinx-build -docs: $(DOCS_INDEX) +docs: $(DOCS_INDEX) $(VENV)/bin/sphinx-build open $(DOCS_INDEX) release: | clean unit functional tests html diff --git a/development.txt b/development.txt index b86aa6f..acbde2f 100644 --- a/development.txt +++ b/development.txt @@ -17,8 +17,8 @@ redis==3.4.1 rednose>=1.3.0 requests-toolbelt>=0.9.1 singledispatch>=3.4.0.3 -sphinx-rtd-theme>=0.4.3 -sphinx>=2.4.4 +sphinx-rtd-theme>=0.5.2 +sphinx>=4.0.1 sure>=1.4.11 tornado>=6.0.4 tox>=3.14.5 diff --git a/docs/Makefile b/docs/Makefile index 898ba26..cf144d4 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,9 +1,12 @@ # Minimal makefile for Sphinx documentation # +GIT_ROOT := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))/..) +VENV_ROOT := $(GIT_ROOT)/.venv +VENV ?= $(VENV_ROOT) # You can set these variables from the command line. SPHINXOPTS = -SPHINXBUILD = sphinx-build +SPHINXBUILD = $(VENV)/bin/sphinx-build SPHINXPROJ = HTTPretty SOURCEDIR = source BUILDDIR = build @@ -17,4 +20,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/source/acks.rst b/docs/source/acks.rst index ceffe06..036b795 100644 --- a/docs/source/acks.rst +++ b/docs/source/acks.rst @@ -1,7 +1,7 @@ Acknowledgements ################ -caveats +Caveats ======= ``forcing_headers`` + ``Content-Length`` @@ -11,7 +11,7 @@ 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 +Supported Libraries ------------------- Because HTTPretty works in the socket level it should work with any HTTP client libraries, although it is `battle tested <https://github.com/gabrielfalcao/HTTPretty/tree/master/tests/functional>`_ against: diff --git a/docs/source/api.rst b/docs/source/api.rst index a94f65b..71fe992 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -9,31 +9,37 @@ 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 @@ -45,6 +51,7 @@ activate .. autoclass:: httpretty.activate :members: + :noindex: .. _httprettified: @@ -53,6 +60,7 @@ httprettified ------------- .. autofunction:: httpretty.core.httprettified + :noindex: .. _enabled: @@ -62,6 +70,7 @@ enabled .. autoclass:: httpretty.enabled :members: + :noindex: .. _httprettized: @@ -71,6 +80,8 @@ httprettized .. autoclass:: httpretty.core.httprettized :members: + :noindex: + .. _HTTPrettyRequest: @@ -80,6 +91,7 @@ HTTPrettyRequest .. autoclass:: httpretty.core.HTTPrettyRequest :members: + :noindex: .. _HTTPrettyRequestEmpty: @@ -89,6 +101,7 @@ HTTPrettyRequestEmpty .. autoclass:: httpretty.core.HTTPrettyRequestEmpty :members: + :noindex: .. _FakeSockFile: @@ -97,6 +110,7 @@ FakeSockFile .. autoclass:: httpretty.core.FakeSockFile :members: + :noindex: .. _FakeSSLSocket: @@ -106,6 +120,7 @@ FakeSSLSocket .. autoclass:: httpretty.core.FakeSSLSocket :members: + :noindex: .. _URIInfo: @@ -115,6 +130,7 @@ URIInfo .. autoclass:: httpretty.URIInfo :members: + :noindex: .. _URIMatcher: @@ -124,6 +140,7 @@ URIMatcher .. autoclass:: httpretty.URIMatcher :members: + :noindex: .. _Entry: @@ -133,6 +150,7 @@ Entry .. autoclass:: httpretty.Entry :members: + :noindex: .. _api modules: diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 2075f71..270764e 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -1,6 +1,26 @@ Release Notes ============= +1.1.0 +----- + +- Feature: Display mismatched URL within ``UnmockedError`` whenever possible. `#388 <https://github.com/gabrielfalcao/HTTPretty/issues/388>`_ +- Feature: Display mismatched URL via logging. `#419 <https://github.com/gabrielfalcao/HTTPretty/pull/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') + + 1.0.5 ----- diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 511b2c2..a5339d2 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -17,13 +17,15 @@ 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 + @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"}]', @@ -37,8 +39,13 @@ Don't worry, HTTPretty is here for you: A more technical description ============================ -HTTPretty is a HTTP client mock library for Python 100% inspired on ruby's [FakeWeb](http://fakeweb.rubyforge.org/). -If you come from ruby this would probably sound familiar :smiley: +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 <http://fakeweb.rubyforge.org/>`_. + +If you come from the Ruby programming language this would probably sound familiar :smiley: Installing ========== @@ -65,7 +72,7 @@ expecting a simple response body import httpretty def test_one(): - httpretty.enable() # enable HTTPretty so that it will monkey patch the socket module + 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") @@ -160,20 +167,16 @@ problem: *"I'm gonna need to mock all those requests"* -It brings a lot of hassle, you will need to use a generic mocking -tool, mess with scope and so on. - -The idea behind HTTPretty (how it works) -======================================== - - -HTTPretty `monkey patches <http://en.wikipedia.org/wiki/Monkey_patch>`_ -Python's `socket <http://docs.python.org/library/socket.html>`_ core -module, reimplementing the HTTP protocol, by mocking requests and -responses. +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. -As for how it works this way, you don't need to worry what http -library you're gonna use. +HTTPretty `monkey patches +<http://en.wikipedia.org/wiki/Monkey_patch>`_ Python's +:py:mod:`socket` core module with a fake version of the module. -HTTPretty will mock the response for you :) *(and also give you the -latest requests so that you can check them)* +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/core.py b/httpretty/core.py index 924551e..f381eaf 100644 --- a/httpretty/core.py +++ b/httpretty/core.py @@ -23,6 +23,7 @@ # OTHER DEALINGS IN THE SOFTWARE. import io +import time import codecs import contextlib import functools @@ -147,8 +148,7 @@ def FALLBACK_FUNCTION(x): class HTTPrettyRequest(BaseHTTPRequestHandler, BaseClass): - r""" - Represents a HTTP request. It takes a valid multi-line, + 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. @@ -161,15 +161,24 @@ class HTTPrettyRequest(BaseHTTPRequestHandler, BaseClass): ``headers`` -> a mimetype object that can be cast into a dictionary, contains all the request headers - ``method`` -> the HTTP method used in this request + ``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 + ``body`` -> the request body as a string. - ``parsed_body`` -> the request body parsed by ``parse_request_body`` + ``parsed_body`` -> the request body parsed by ``parse_request_body``. .. testcode:: @@ -177,16 +186,15 @@ class HTTPrettyRequest(BaseHTTPRequestHandler, BaseClass): {'name': ['Gabriel Falcao']} >>> print request.querystring['name'][0] - - """ - def __init__(self, headers, body=''): + 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])) @@ -206,14 +214,13 @@ class HTTPrettyRequest(BaseHTTPRequestHandler, BaseClass): if not self.parse_request(): return - # making the HTTP method string available as the command - self.method = self.command - # Now 2 convenient attributes for the HTTPretty API: - # `querystring` holds a dictionary with the parsed query string + # - `path` + # - `querystring` holds a dictionary with the parsed query string + # - `parsed_body` a string try: - self.path = self.path.encode('iso-8859-1') + self.path = self.path.encode(path_encoding) except UnicodeDecodeError: pass @@ -233,6 +240,25 @@ class HTTPrettyRequest(BaseHTTPRequestHandler, BaseClass): 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 @@ -247,11 +273,21 @@ class HTTPrettyRequest(BaseHTTPRequestHandler, BaseClass): 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 '<unknown>' + def __str__(self): - tmpl = '<HTTPrettyRequest("{}", total_headers={}, body_length={})>' + tmpl = '<HTTPrettyRequest("{}", "{}", headers={}, body={})>' return tmpl.format( - self.headers.get('content-type', ''), - len(self.headers), + self.method, + self.url, + dict(self.headers), len(self.body), ) @@ -375,7 +411,7 @@ class fakesock(object): _entry = None debuglevel = 0 _sent_data = [] - + is_secure = False def __init__( self, family=socket.AF_INET, @@ -400,7 +436,14 @@ class fakesock(object): self.is_http = False self._bufsize = 32 * 1024 - def create_socket(self): + 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): @@ -452,14 +495,15 @@ class fakesock(object): 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() + 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() + self.connect_truesock(address=address) def bind(self, address): self._address = (self._host, self._port) = address @@ -470,18 +514,28 @@ class fakesock(object): if httpretty.allow_net_connect and not self.truesock: self.truesock = self.create_socket() elif not self.truesock: - raise UnmockedError() + 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): + 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() + self.truesock = self.create_socket(address) elif not self.truesock: - raise UnmockedError() + 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] @@ -549,17 +603,22 @@ class fakesock(object): 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() + + self.connect_truesock(request=request) elif not self.truesock: - raise UnmockedError() + raise UnmockedError(request=request) if not self.is_http: self.truesock.setblocking(1) return self.truesock.sendall(data, *args, **kw) - sock = self.connect_truesock() + sock = self.connect_truesock(request=request) sock.setblocking(1) sock.sendall(data, *args, **kw) @@ -621,7 +680,7 @@ class fakesock(object): else: self._entry.request.body += body - httpretty.historify_request(headers, body, False) + httpretty.historify_request(headers, body, sock=self) return if path[:2] == '//': @@ -636,7 +695,7 @@ class fakesock(object): headers = '' body = data - request = httpretty.historify_request(headers, body) + request = httpretty.historify_request(headers, body, sock=self) info = URIInfo( hostname=self._host, @@ -650,14 +709,14 @@ class fakesock(object): if not entries: self._entry = None - self.real_sendall(data) + 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() + 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) @@ -688,7 +747,9 @@ class fakesock(object): return self.forward_and_trace('recv', buffersize, *args, **kwargs) def __getattr__(self, name): - if httpretty.allow_net_connect and not self.truesock: + if name in ('getsockopt', ) 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?) @@ -702,22 +763,34 @@ class fakesock(object): "(see issue https://github.com/gabrielfalcao/HTTPretty/issues/409). " "Please open an issue if this error causes further unexpected issues." ) - raise UnmockedError() + + 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: - return orig_wrap_socket_fn(*args, **kw) - if 'sock' in kw: - return kw['sock'] - else: - return args[0] + 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( @@ -1285,7 +1358,7 @@ class httpretty(HttpBaseClass): @classmethod @contextlib.contextmanager - def record(cls, filename, indentation=4, encoding='utf-8'): + def record(cls, filename, indentation=4, encoding='utf-8', verbose=False, allow_net_connect=True, pool_manager_params=None): """ .. testcode:: @@ -1315,9 +1388,9 @@ class httpretty(HttpBaseClass): ) raise RuntimeError(msg) - http = urllib3.PoolManager() + http = urllib3.PoolManager(**pool_manager_params or {}) - cls.enable() + cls.enable(allow_net_connect, verbose=verbose) calls = [] def record_request(request, uri, headers): @@ -1346,7 +1419,7 @@ class httpretty(HttpBaseClass): 'headers': dict(response.headers.items()) } }) - cls.enable() + cls.enable(allow_net_connect, verbose=verbose) return response.status, response.headers, response.data for method in cls.METHODS: @@ -1359,7 +1432,7 @@ class httpretty(HttpBaseClass): @classmethod @contextlib.contextmanager - def playback(cls, filename): + def playback(cls, filename, allow_net_connect=True, verbose=False): """ .. testcode:: @@ -1377,7 +1450,7 @@ class httpretty(HttpBaseClass): :param filename: a string :returns: a `context-manager <https://docs.python.org/3/reference/datamodel.html#context-managers>`_ """ - cls.enable() + cls.enable(allow_net_connect, verbose=verbose) data = json.loads(open(filename).read()) for item in data: @@ -1401,7 +1474,7 @@ class httpretty(HttpBaseClass): cls.last_request = HTTPrettyRequestEmpty() @classmethod - def historify_request(cls, headers, body='', append=True): + def historify_request(cls, headers, body='', sock=None): """appends request to a list for later retrieval .. testcode:: @@ -1414,12 +1487,16 @@ class httpretty(HttpBaseClass): assert httpretty.latest_requests[-1].url == 'https://httpbin.org/ip' """ - request = HTTPrettyRequest(headers, body) + request = HTTPrettyRequest(headers, body, sock=sock) cls.last_request = request - if append or not cls.latest_requests: + + if request not in cls.latest_requests: cls.latest_requests.append(request) else: - cls.latest_requests[-1] = request + pos = cls.latest_requests.index(request) + cls.latest_requests[pos] = request + + logger.info("captured: {}".format(request)) return request @classmethod @@ -1577,16 +1654,18 @@ class httpretty(HttpBaseClass): return cls._is_enabled @classmethod - def enable(cls, allow_net_connect=True): + def enable(cls, allow_net_connect=True, verbose=False): """Enables HTTPretty. - When ``allow_net_connect`` is ``False`` any connection to an unregistered uri will throw :py:class:`httpretty.errors.UnmockedError`. + + :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() + httpretty.enable(allow_net_connect=True, verbose=True) httpretty.register_uri( httpretty.GET, @@ -1603,9 +1682,13 @@ class httpretty(HttpBaseClass): .. 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 <https://docs.python.org/3/reference/datamodel.html#context-managers>`_ """ - cls.allow_net_connect = allow_net_connect + 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) def apply_patch_socket(): @@ -1741,19 +1824,20 @@ class httprettized(object): 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): + 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) + 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): +def httprettified(test=None, allow_net_connect=True, verbose=False): """decorator for test functions .. tip:: Also available under the alias :py:func:`httpretty.activate` @@ -1811,7 +1895,7 @@ def httprettified(test=None, allow_net_connect=True): def new_setUp(self): httpretty.reset() - httpretty.enable(allow_net_connect) + httpretty.enable(allow_net_connect, verbose=verbose) if use_addCleanup: self.addCleanup(httpretty.disable) if original_setUp: @@ -1851,7 +1935,6 @@ def httprettified(test=None, allow_net_connect=True): except ImportError: return False - "A decorator for tests that use HTTPretty" def decorate_class(klass): if is_unittest_TestCase(klass): return decorate_unittest_TestCase_setUp(klass) diff --git a/httpretty/errors.py b/httpretty/errors.py index d0a55a9..10faca3 100644 --- a/httpretty/errors.py +++ b/httpretty/errors.py @@ -25,15 +25,24 @@ # 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): - super(UnmockedError, self).__init__( - 'No mocking was registered, and real connections are ' - 'not allowed (httpretty.allow_net_connect = False).' - ) + 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/tests/functional/bugfixes/test_242_ssl_bad_handshake.py b/tests/functional/bugfixes/test_242_ssl_bad_handshake.py index 7e4bbc0..0653b41 100644 --- a/tests/functional/bugfixes/test_242_ssl_bad_handshake.py +++ b/tests/functional/bugfixes/test_242_ssl_bad_handshake.py @@ -14,3 +14,12 @@ def test_test_ssl_bad_handshake(): 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/functional/bugfixes/test_388_unmocked_error_with_url.py b/tests/functional/bugfixes/test_388_unmocked_error_with_url.py new file mode 100644 index 0000000..751d0ad --- /dev/null +++ b/tests/functional/bugfixes/test_388_unmocked_error_with_url.py @@ -0,0 +1,56 @@ +# <HTTPretty - HTTP client mock for Python> +# Copyright (C) <2011-2021> Gabriel Falcão <gabriel@nacaolivre.org> +# +# 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(): + "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/functional/test_bypass.py b/tests/functional/test_bypass.py index 97dec3d..904bc12 100644 --- a/tests/functional/test_bypass.py +++ b/tests/functional/test_bypass.py @@ -120,7 +120,7 @@ def test_httpretty_bypasses_when_disabled(context): core.POTENTIAL_HTTP_PORTS.remove(context.http_port) -@httpretty.activate +@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" @@ -143,7 +143,7 @@ def test_httpretty_bypasses_a_unregistered_request(context): core.POTENTIAL_HTTP_PORTS.remove(context.http_port) -@httpretty.activate +@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" @@ -163,7 +163,7 @@ def test_using_httpretty_with_other_tcp_protocols(context): @httpretty.activate(allow_net_connect=False) @that_with_context(start_http_server, stop_http_server) -def test_disallow_net_connect_1(context): +def test_disallow_net_connect_1(context, verbose=True): """ When allow_net_connect = False, a request that otherwise would have worked results in UnmockedError. diff --git a/tests/functional/test_passthrough.py b/tests/functional/test_passthrough.py index 5bb3485..14ad2ae 100644 --- a/tests/functional/test_passthrough.py +++ b/tests/functional/test_passthrough.py @@ -24,7 +24,6 @@ import requests import httpretty -from unittest import skip from sure import expect @@ -42,15 +41,15 @@ def test_http_passthrough(): response1 = http().get(url) - httpretty.enable() + 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="") + 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(response1.content) + response3.content.should.equal(b"mocked") httpretty.disable() @@ -63,7 +62,7 @@ def test_https_passthrough(): response1 = http().get(url) - httpretty.enable() + httpretty.enable(allow_net_connect=True, verbose=True) httpretty.register_uri(httpretty.GET, 'https://google.com/', body="Not Google") httpretty.register_uri(httpretty.GET, url, body="mocked") diff --git a/tests/functional/test_requests.py b/tests/functional/test_requests.py index 0ee6ab9..7da9ce3 100644 --- a/tests/functional/test_requests.py +++ b/tests/functional/test_requests.py @@ -30,7 +30,6 @@ import signal import httpretty from freezegun import freeze_time -from unittest import skip from contextlib import contextmanager from sure import within, microseconds, expect from tornado import version as tornado_version @@ -388,7 +387,7 @@ def test_streaming_responses(now): @httprettified def test_multiline(): - url = 'http://httpbin.org/post' + 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', @@ -402,16 +401,18 @@ def test_multiline(): 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(1) + expect(len(HTTPretty.latest_requests)).to.equal(2) @httprettified def test_octet_stream(): - url = 'http://httpbin.org/post' + url = 'https://httpbin.org/post' data = b"\xf5\x00\x00\x00" # utf-8 with invalid start byte headers = { 'Content-Type': 'application/octet-stream', @@ -424,16 +425,18 @@ def test_octet_stream(): 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(1) + expect(len(HTTPretty.latest_requests)).to.equal(2) @httprettified def test_multipart(): - url = 'http://httpbin.org/post' + 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( @@ -443,11 +446,13 @@ def test_multipart(): 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(1) + expect(len(HTTPretty.latest_requests)).to.equal(2) @httprettified @@ -621,8 +626,7 @@ def test_httpretty_provides_easy_access_to_querystrings_with_regexes(): }) -@skip('TODO: refactor this flaky test') -@httprettified +@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" diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index c73836c..80c4a86 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -159,15 +159,16 @@ def test_request_string_representation(): 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) + request = HTTPrettyRequest(headers, body, sock=Mock(is_https=True)) # Then its string representation should show the headers and the body - str(request).should.equal('<HTTPrettyRequest("JPEG-baby", total_headers=1, body_length=14)>') + str(request).should.equal('<HTTPrettyRequest("POST", "https://blog.falcao.it/create", headers={\'Content-Type\': \'JPEG-baby\', \'Host\': \'blog.falcao.it\'}, body=14)>') def test_fake_ssl_socket_proxies_its_ow_socket(): @@ -297,6 +298,7 @@ def test_fakesock_socket_real_sendall(old_socket): # 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') |