diff --git a/docs/index.rst b/docs/index.rst index cfcb1f6..197ea80 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,7 @@ Contents: mocker matching response + knownissues history adapter contrib diff --git a/docs/knownissues.rst b/docs/knownissues.rst new file mode 100644 index 0000000..ad83df9 --- /dev/null +++ b/docs/knownissues.rst @@ -0,0 +1,34 @@ +============ +Known Issues +============ + +.. _case_insensitive: + +Case Insensitivity +------------------ + +By default matching is done in a completely case insensitive way. This makes +sense for the protocol and host components which are defined as insensitive by +RFCs however it does not make sense for path. + +A byproduct of this is that when using request history the values for path, qs +etc are all lowercased as this was what was used to do the matching. + +To work around this when building an Adapter or Mocker you do + +.. code:: python + + with requests_mock.mock(case_sensitive=True) as m: + ... + +or you can override the default globally by + +.. code:: python + + requests_mock.mock.case_sensitive = True + +It is recommended to run the global fix as it is intended that case sensitivity +will become the default in future releases. + +Note that even with case_sensitive enabled the protocol and netloc of a mock +are still matched in a case insensitive way. diff --git a/docs/matching.rst b/docs/matching.rst index fe418c8..3297196 100644 --- a/docs/matching.rst +++ b/docs/matching.rst @@ -38,6 +38,18 @@ The examples in this file are loaded with: >>> session = requests.Session() >>> session.mount('mock', adapter) +.. note:: + + By default all matching is case insensitive. This can be adjusted by + passing case_sensitive=True when creating a mocker or adapter or globally + by doing: + + .. code:: python + + requests_mock.mock.case_sensitive = True + + for more see: :ref:`case_insensitive` + Simple ====== diff --git a/releasenotes/notes/case-insensitive-matching-a3143221359bbf2d.yaml b/releasenotes/notes/case-insensitive-matching-a3143221359bbf2d.yaml new file mode 100644 index 0000000..bdee75a --- /dev/null +++ b/releasenotes/notes/case-insensitive-matching-a3143221359bbf2d.yaml @@ -0,0 +1,17 @@ +--- +prelude: > + It is now possible to make URL matching and request history not lowercase + the provided URLs. +features: + - You can pass case_sensitive=True to an adapter or set + `requests_mock.mock.case_sensitive = True` globally to enable case + sensitive matching. +upgrade: + - It is recommended you add `requests_mock.mock.case_sensitive = True` to + your base test file to globally turn on case sensitive matching as this + will become the default in a 2.X release. +fixes: + - Reported in bug \#1584008 all request matching is done in a case + insensitive way, as a byproduct of this request history is handled in a + case insensitive way. This can now be controlled by setting case_sensitive + to True when creating an adapter or globally. diff --git a/requests_mock/adapter.py b/requests_mock/adapter.py index b4aad5f..15fa9ee 100644 --- a/requests_mock/adapter.py +++ b/requests_mock/adapter.py @@ -46,13 +46,22 @@ class _RequestObjectProxy(object): self._cert = kwargs.pop('cert', None) self._proxies = copy.deepcopy(kwargs.pop('proxies', {})) + # FIXME(jamielennox): This is part of bug #1584008 and should default + # to True (or simply removed) in a major version bump. + self._case_sensitive = kwargs.pop('case_sensitive', False) + def __getattr__(self, name): return getattr(self._request, name) @property def _url_parts(self): if self._url_parts_ is None: - self._url_parts_ = urlparse.urlparse(self._request.url.lower()) + url = self._request.url + + if not self._case_sensitive: + url = url.lower() + + self._url_parts_ = urlparse.urlparse(url) return self._url_parts_ @@ -169,7 +178,7 @@ class _Matcher(_RequestHistoryTracker): """Contains all the information about a provided URL to match.""" def __init__(self, method, url, responses, complete_qs, request_headers, - real_http): + real_http, case_sensitive): """ :param bool complete_qs: Match the entire query string. By default URLs match if all the provided matcher query arguments are matched and @@ -180,15 +189,29 @@ class _Matcher(_RequestHistoryTracker): self._method = method self._url = url - try: - self._url_parts = urlparse.urlparse(url.lower()) - except: - self._url_parts = None self._responses = responses self._complete_qs = complete_qs self._request_headers = request_headers self._real_http = real_http + # url can be a regex object or ANY so don't always run urlparse + if isinstance(url, six.string_types): + url_parts = urlparse.urlparse(url) + self._scheme = url_parts.scheme.lower() + self._netloc = url_parts.netloc.lower() + self._path = url_parts.path or '/' + self._query = url_parts.query + + if not case_sensitive: + self._path = self._path.lower() + self._query = self._query.lower() + + else: + self._scheme = None + self._netloc = None + self._path = None + self._query = None + def _match_method(self, request): if self._method is ANY: return True @@ -206,18 +229,20 @@ class _Matcher(_RequestHistoryTracker): if hasattr(self._url, 'search'): return self._url.search(request.url) is not None - if self._url_parts.scheme and request.scheme != self._url_parts.scheme: + # scheme is always matched case insensitive + if self._scheme and request.scheme.lower() != self._scheme: return False - if self._url_parts.netloc and request.netloc != self._url_parts.netloc: + # netloc is always matched case insensitive + if self._netloc and request.netloc.lower() != self._netloc: return False - if (request.path or '/') != (self._url_parts.path or '/'): + if (request.path or '/') != self._path: return False # construct our own qs structure as we remove items from it below request_qs = urlparse.parse_qs(request.query) - matcher_qs = urlparse.parse_qs(self._url_parts.query) + matcher_qs = urlparse.parse_qs(self._query) for k, vals in six.iteritems(matcher_qs): for v in vals: @@ -283,12 +308,15 @@ class Adapter(BaseAdapter, _RequestHistoryTracker): """A fake adapter than can return predefined responses. """ - def __init__(self): + def __init__(self, case_sensitive=False): super(Adapter, self).__init__() + self._case_sensitive = case_sensitive self._matchers = [] def send(self, request, **kwargs): - request = _RequestObjectProxy(request, **kwargs) + request = _RequestObjectProxy(request, + case_sensitive=self._case_sensitive, + **kwargs) self._add_to_history(request) for matcher in reversed(self._matchers): @@ -327,10 +355,17 @@ class Adapter(BaseAdapter, _RequestHistoryTracker): elif not response_list: response_list = [] if real_http else [kwargs] + # NOTE(jamielennox): case_sensitive is not present as a kwarg because i + # think there would be an edge case where the adapter and register_uri + # had different values. + # Ideally case_sensitive would be a value passed to match() however + # this would change the contract of matchers so we pass ito to the + # proxy and the matcher seperately. responses = [response._MatcherResponse(**k) for k in response_list] matcher = _Matcher(method, url, responses, + case_sensitive=self._case_sensitive, complete_qs=complete_qs, request_headers=request_headers, real_http=real_http) diff --git a/requests_mock/mocker.py b/requests_mock/mocker.py index c7df6af..f477eba 100644 --- a/requests_mock/mocker.py +++ b/requests_mock/mocker.py @@ -39,8 +39,28 @@ class MockerCore(object): 'called', 'call_count']) + case_sensitive = False + """case_sensitive handles a backwards incompatible bug. The URL used to + match against our matches and that is saved in request_history is always + lowercased. This is incorrect as it reports incorrect history to the user + and doesn't allow case sensitive path matching. + + Unfortunately fixing this change is backwards incompatible in the 1.X + series as people may rely on this behaviour. To work around this you can + globally set: + + requests_mock.mock.case_sensitive = True + + which will prevent the lowercase being executed and return case sensitive + url and query information. + + This will become the default in a 2.X release. See bug: #1584008. + """ + def __init__(self, **kwargs): - self._adapter = adapter.Adapter() + case_sensitive = kwargs.pop('case_sensitive', self.case_sensitive) + self._adapter = adapter.Adapter(case_sensitive=case_sensitive) + self._real_http = kwargs.pop('real_http', False) self._real_send = None diff --git a/requests_mock/tests/test_adapter.py b/requests_mock/tests/test_adapter.py index dc82d66..8ad86ca 100644 --- a/requests_mock/tests/test_adapter.py +++ b/requests_mock/tests/test_adapter.py @@ -683,3 +683,41 @@ class SessionAdapterTests(base.TestCase): self.assertIsInstance(data, six.binary_type) self.assertEqual(0, len(data)) + + def test_case_sensitive_headers(self): + data = 'testdata' + headers = {'aBcDe': 'FgHiJ'} + + self.adapter.register_uri('GET', self.url, text=data) + resp = self.session.get(self.url, headers=headers) + + self.assertEqual('GET', self.adapter.last_request.method) + self.assertEqual(200, resp.status_code) + self.assertEqual(data, resp.text) + + for k, v in headers.items(): + self.assertEqual(v, self.adapter.last_request.headers[k]) + + def test_case_sensitive_history(self): + self.adapter._case_sensitive = True + + data = 'testdata' + netloc = 'examPlE.CoM' + path = '/TesTER' + query = 'aBC=deF' + + mock_url = '%s://%s%s' % (self.PREFIX, netloc.lower(), path) + request_url = '%s://%s%s?%s' % (self.PREFIX, netloc, path, query) + + # test that the netloc is ignored when actually making the request + self.adapter.register_uri('GET', mock_url, text=data) + resp = self.session.get(request_url) + + self.assertEqual('GET', self.adapter.last_request.method) + self.assertEqual(200, resp.status_code) + self.assertEqual(data, resp.text) + + # but even still the mixed case parameters come out in history + self.assertEqual(netloc, self.adapter.last_request.netloc) + self.assertEqual(path, self.adapter.last_request.path) + self.assertEqual(query, self.adapter.last_request.query) diff --git a/requests_mock/tests/test_matcher.py b/requests_mock/tests/test_matcher.py index b0e9b1c..660fc7b 100644 --- a/requests_mock/tests/test_matcher.py +++ b/requests_mock/tests/test_matcher.py @@ -28,13 +28,15 @@ class TestMatcher(base.TestCase): complete_qs=False, headers=None, request_headers={}, - real_http=False): + real_http=False, + case_sensitive=False): matcher = adapter._Matcher(matcher_method, target, [], complete_qs, request_headers, - real_http=real_http) + real_http=real_http, + case_sensitive=case_sensitive) request = adapter._RequestObjectProxy._create(request_method, url, headers) @@ -222,3 +224,44 @@ class TestMatcher(base.TestCase): 'http://www.test.com/path', headers={'A': 'abc', 'b': 'def'}, request_headers={'c': 'ghi'}) + + # headers should be key insensitive and value sensitive, we have no + # choice here because they go into an insensitive dict. + self.assertMatch('/path', + 'http://www.test.com/path', + headers={'aBc': 'abc', 'DEF': 'def'}, + request_headers={'abC': 'abc'}) + + self.assertNoMatch('/path', + 'http://www.test.com/path', + headers={'abc': 'aBC', 'DEF': 'def'}, + request_headers={'abc': 'Abc'}) + + def test_case_sensitive_ignored_for_netloc_and_protocol(self): + for case_sensitive in (True, False): + self.assertMatch('http://AbC.CoM', + 'http://aBc.CoM', + case_sensitive=case_sensitive) + + self.assertMatch('htTP://abc.com', + 'hTTp://abc.com', + case_sensitive=case_sensitive) + + self.assertMatch('htTP://aBC.cOm', + 'hTTp://AbC.Com', + case_sensitive=case_sensitive) + + def assertSensitiveMatch(self, target, url, **kwargs): + self.assertMatch(target, url, case_sensitive=False, **kwargs) + self.assertNoMatch(target, url, case_sensitive=True, **kwargs) + + def test_case_sensitive_paths(self): + self.assertSensitiveMatch('http://abc.com/pAtH', 'http://abc.com/path') + self.assertSensitiveMatch('/pAtH', 'http://abc.com/path') + + def test_case_sensitive_query(self): + self.assertSensitiveMatch('http://abc.com/path?abCD=efGH', + 'http://abc.com/path?abCd=eFGH') + + self.assertSensitiveMatch('http://abc.com/path?abcd=efGH', + 'http://abc.com/path?abcd=eFGH') diff --git a/requests_mock/tests/test_mocker.py b/requests_mock/tests/test_mocker.py index 7eaad6e..5a81db9 100644 --- a/requests_mock/tests/test_mocker.py +++ b/requests_mock/tests/test_mocker.py @@ -288,3 +288,34 @@ class MockerHttpMethodsTests(base.TestCase): # do it again to make sure the mock is still in place self.assertEqual(data, requests.get(uri1).text) + + @requests_mock.Mocker(case_sensitive=True) + def test_case_sensitive_query(self, m): + data = 'testdata' + query = {'aBcDe': 'FgHiJ'} + + m.get(self.URL, text=data) + resp = requests.get(self.URL, params=query) + + self.assertEqual('GET', m.last_request.method) + self.assertEqual(200, resp.status_code) + self.assertEqual(data, resp.text) + + for k, v in query.items(): + self.assertEqual([v], m.last_request.qs[k]) + + @mock.patch.object(requests_mock.Mocker, 'case_sensitive', True) + def test_global_case_sensitive(self): + with requests_mock.mock() as m: + data = 'testdata' + query = {'aBcDe': 'FgHiJ'} + + m.get(self.URL, text=data) + resp = requests.get(self.URL, params=query) + + self.assertEqual('GET', m.last_request.method) + self.assertEqual(200, resp.status_code) + self.assertEqual(data, resp.text) + + for k, v in query.items(): + self.assertEqual([v], m.last_request.qs[k])