Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-responses for openSUSE:Factory checked in at 2022-02-17 23:40:01 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-responses (Old) and /work/SRC/openSUSE:Factory/.python-responses.new.1958 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-responses" Thu Feb 17 23:40:01 2022 rev:18 rq:955500 version:0.17.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-responses/python-responses.changes 2021-12-09 19:45:04.281117718 +0100 +++ /work/SRC/openSUSE:Factory/.python-responses.new.1958/python-responses.changes 2022-02-17 23:40:56.091700712 +0100 @@ -1,0 +2,18 @@ +Wed Feb 16 23:14:16 UTC 2022 - Dirk M??ller <dmuel...@suse.com> + +- update to 0.17.0: + * This release is the last to support Python 2.7. + * Fixed issue when `response.iter_content` when `chunk_size=None` entered infinite loop + * Fixed issue when `passthru_prefixes` persisted across tests. + Now `add_passthru` is valid only within a context manager or for a single function and + cleared on exit + * Deprecate `match_querystring` argument in `Response` and `CallbackResponse`. + Use `responses.matchers.query_param_matcher` or `responses.matchers.query_string_matcher` + * Added support for non-UTF-8 bytes in `responses.matchers.multipart_matcher` + * Added `responses.registries`. Now user can create custom registries to + manipulate the order of responses in the match algorithm + `responses.activate(registry=CustomRegistry)` + * Fixed issue with response match when requests were performed between adding responses with + same URL. See Issue #212 + +------------------------------------------------------------------- Old: ---- responses-0.16.0.tar.gz New: ---- responses-0.17.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-responses.spec ++++++ --- /var/tmp/diff_new_pack.iRqrSR/_old 2022-02-17 23:40:56.623700707 +0100 +++ /var/tmp/diff_new_pack.iRqrSR/_new 2022-02-17 23:40:56.627700707 +0100 @@ -1,7 +1,7 @@ # # spec file for package python-responses # -# Copyright (c) 2021 SUSE LLC +# Copyright (c) 2022 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -18,7 +18,7 @@ %{?!python_module:%define python_module() python-%{**} python3-%{**}} Name: python-responses -Version: 0.16.0 +Version: 0.17.0 Release: 0 Summary: A utility library for mocking out the `requests` Python library License: Apache-2.0 ++++++ responses-0.16.0.tar.gz -> responses-0.17.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/CHANGES new/responses-0.17.0/CHANGES --- old/responses-0.16.0/CHANGES 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/CHANGES 2022-01-07 17:18:13.000000000 +0100 @@ -1,3 +1,20 @@ +0.17.0 +------ + +* This release is the last to support Python 2.7. +* Fixed issue when `response.iter_content` when `chunk_size=None` entered infinite loop +* Fixed issue when `passthru_prefixes` persisted across tests. + Now `add_passthru` is valid only within a context manager or for a single function and + cleared on exit +* Deprecate `match_querystring` argument in `Response`` and `CallbackResponse`. + Use `responses.matchers.query_param_matcher` or `responses.matchers.query_string_matcher` +* Added support for non-UTF-8 bytes in `responses.matchers.multipart_matcher` +* Added `responses.registries`. Now user can create custom registries to + manipulate the order of responses in the match algorithm + `responses.activate(registry=CustomRegistry)` +* Fixed issue with response match when requests were performed between adding responses with + same URL. See Issue #212 + 0.16.0 ------ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/MANIFEST.in new/responses-0.17.0/MANIFEST.in --- old/responses-0.16.0/MANIFEST.in 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/MANIFEST.in 2022-01-07 17:18:13.000000000 +0100 @@ -1,4 +1,5 @@ include README.rst CHANGES LICENSE -include test_responses.py tox.ini +include test_responses.py test_matchers.py include **/*.pyi +include tox.ini global-exclude *~ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/PKG-INFO new/responses-0.17.0/PKG-INFO --- old/responses-0.16.0/PKG-INFO 2021-11-11 19:03:24.265085000 +0100 +++ new/responses-0.17.0/PKG-INFO 2022-01-07 17:18:17.088163000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: responses -Version: 0.16.0 +Version: 0.17.0 Summary: A utility library for mocking out the `requests` Python library. Home-page: https://github.com/getsentry/responses Author: David Cramer @@ -129,6 +129,9 @@ The full resource URL. match_querystring (``bool``) + DEPRECATED: Use `responses.matchers.query_param_matcher` or + `responses.matchers.query_string_matcher` + Include the query string when matching requests. Enabled by default if the response URL contains a query string, disabled if it doesn't or the URL is a regular expression. @@ -294,7 +297,7 @@ req_files = {"file_name": b"Old World!"} responses.add( responses.POST, url="http://httpbin.org/post", - match=[multipart_matcher(req_data, req_files)] + match=[multipart_matcher(req_files, data=req_data)] ) resp = requests.post("http://httpbin.org/post", files={"file_name": b"New World!"}) @@ -411,6 +414,49 @@ resp = session.send(prepped) assert resp.text == "hello world" +Response Registry +--------------------------- + +By default, ``responses`` will search all registered``Response`` objects and +return a match. If only one ``Response`` is registered, the registry is kept unchanged. +However, if multiple matches are found for the same request, then first match is returned and +removed from registry. + +Such behavior is suitable for most of use cases, but to handle special conditions, you can +implement custom registry which must follow interface of ``registries.FirstMatchRegistry``. +Redefining the ``find`` method will allow you to create custom search logic and return +appropriate ``Response`` + +Example that shows how to set custom registry + +.. code-block:: python + + import responses + from responses import registries + + + class CustomRegistry(registries.FirstMatchRegistry): + pass + + + """ Before tests: <responses.registries.FirstMatchRegistry object> """ + + # using function decorator + @responses.activate(registry=CustomRegistry) + def run(): + """ Within test: <__main__.CustomRegistry object> """ + + run() + """ After test: <responses.registries.FirstMatchRegistry object> """ + + # using context manager + with responses.RequestsMock(registry=CustomRegistry) as rsps: + """ In context manager: <__main__.CustomRegistry object> """ + + """ + After exit from context manager: <responses.registries.FirstMatchRegistry object> + """ + Dynamic Responses ----------------- @@ -590,22 +636,26 @@ .. code-block:: python - def setUp(): - self.responses = responses.RequestsMock() - self.responses.start() - - # self.responses.add(...) - - self.addCleanup(self.responses.stop) - self.addCleanup(self.responses.reset) + class TestMyApi(unittest.TestCase): + def setUp(self): + responses.add(responses.GET, 'https://example.com', body="within setup") + # here go other self.responses.add(...) + + @responses.activate + def test_my_func(self): + responses.add( + responses.GET, + "https://httpbin.org/get", + match=[matchers.query_param_matcher({"test": "1", "didi": "pro"})], + body="within test" + ) + resp = requests.get("https://example.com") + resp2 = requests.get("https://httpbin.org/get", params={"test": "1", "didi": "pro"}) + print(resp.text) + # >>> within setup + print(resp2.text) + # >>> within test - def test_api(self): - self.responses.add( - responses.GET, 'http://twitter.com/api/1/foobar', - body='{}', status=200, - content_type='application/json') - resp = requests.get('http://twitter.com/api/1/foobar') - assert resp.status_code == 200 Assertions on declared responses -------------------------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/README.rst new/responses-0.17.0/README.rst --- old/responses-0.16.0/README.rst 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/README.rst 2022-01-07 17:18:13.000000000 +0100 @@ -102,6 +102,9 @@ The full resource URL. match_querystring (``bool``) + DEPRECATED: Use `responses.matchers.query_param_matcher` or + `responses.matchers.query_string_matcher` + Include the query string when matching requests. Enabled by default if the response URL contains a query string, disabled if it doesn't or the URL is a regular expression. @@ -267,7 +270,7 @@ req_files = {"file_name": b"Old World!"} responses.add( responses.POST, url="http://httpbin.org/post", - match=[multipart_matcher(req_data, req_files)] + match=[multipart_matcher(req_files, data=req_data)] ) resp = requests.post("http://httpbin.org/post", files={"file_name": b"New World!"}) @@ -384,6 +387,49 @@ resp = session.send(prepped) assert resp.text == "hello world" +Response Registry +--------------------------- + +By default, ``responses`` will search all registered``Response`` objects and +return a match. If only one ``Response`` is registered, the registry is kept unchanged. +However, if multiple matches are found for the same request, then first match is returned and +removed from registry. + +Such behavior is suitable for most of use cases, but to handle special conditions, you can +implement custom registry which must follow interface of ``registries.FirstMatchRegistry``. +Redefining the ``find`` method will allow you to create custom search logic and return +appropriate ``Response`` + +Example that shows how to set custom registry + +.. code-block:: python + + import responses + from responses import registries + + + class CustomRegistry(registries.FirstMatchRegistry): + pass + + + """ Before tests: <responses.registries.FirstMatchRegistry object> """ + + # using function decorator + @responses.activate(registry=CustomRegistry) + def run(): + """ Within test: <__main__.CustomRegistry object> """ + + run() + """ After test: <responses.registries.FirstMatchRegistry object> """ + + # using context manager + with responses.RequestsMock(registry=CustomRegistry) as rsps: + """ In context manager: <__main__.CustomRegistry object> """ + + """ + After exit from context manager: <responses.registries.FirstMatchRegistry object> + """ + Dynamic Responses ----------------- @@ -563,22 +609,26 @@ .. code-block:: python - def setUp(): - self.responses = responses.RequestsMock() - self.responses.start() - - # self.responses.add(...) - - self.addCleanup(self.responses.stop) - self.addCleanup(self.responses.reset) + class TestMyApi(unittest.TestCase): + def setUp(self): + responses.add(responses.GET, 'https://example.com', body="within setup") + # here go other self.responses.add(...) + + @responses.activate + def test_my_func(self): + responses.add( + responses.GET, + "https://httpbin.org/get", + match=[matchers.query_param_matcher({"test": "1", "didi": "pro"})], + body="within test" + ) + resp = requests.get("https://example.com") + resp2 = requests.get("https://httpbin.org/get", params={"test": "1", "didi": "pro"}) + print(resp.text) + # >>> within setup + print(resp2.text) + # >>> within test - def test_api(self): - self.responses.add( - responses.GET, 'http://twitter.com/api/1/foobar', - body='{}', status=200, - content_type='application/json') - resp = requests.get('http://twitter.com/api/1/foobar') - assert resp.status_code == 200 Assertions on declared responses -------------------------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/responses/__init__.py new/responses-0.17.0/responses/__init__.py --- old/responses-0.16.0/responses/__init__.py 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/responses/__init__.py 2022-01-07 17:18:13.000000000 +0100 @@ -16,6 +16,8 @@ from requests.utils import cookiejar_from_dict from responses.matchers import json_params_matcher as _json_params_matcher from responses.matchers import urlencoded_params_matcher as _urlencoded_params_matcher +from responses.registries import FirstMatchRegistry +from responses.matchers import query_string_matcher as _query_string_matcher from warnings import warn try: @@ -76,6 +78,13 @@ logger = logging.getLogger("responses") +if six.PY2: + warn( + "Support for Python 2.7 is being removed from the next release of responses. " + "Pin your dependency to responses==0.17 or lower.", + DeprecationWarning, + ) + def urlencoded_params_matcher(params): warn( @@ -156,7 +165,7 @@ """ -def get_wrapped(func, responses): +def get_wrapped(func, responses, registry=None): if six.PY2: args, a, kw, defaults = inspect.getargspec(func) wrapper_args = inspect.formatargspec(args, a, kw, defaults) @@ -193,6 +202,9 @@ signature = signature.replace(parameters=params_without_defaults) func_args = str(signature) + if registry is not None: + responses._set_registry(registry) + evaldict = {"func": func, "responses": responses} six.exec_( _wrapper_template % {"wrapper_args": wrapper_args, "func_args": func_args}, @@ -246,10 +258,35 @@ if isinstance(body, _io.BufferedReader): return body - return BufferIO(body) + data = BufferIO(body) + + def is_closed(): + """ + Real Response uses HTTPResponse as body object. + Thus, when method is_closed is called first to check if there is any more + content to consume and the file-like object is still opened + + This method ensures stability to work for both: + https://github.com/getsentry/responses/issues/438 + https://github.com/getsentry/responses/issues/394 + + where file should be intentionally be left opened to continue consumption + """ + if not data.closed and data.read(1): + # if there is more bytes to read then keep open, but return pointer + data.seek(-1, 1) + return False + else: + if not data.closed: + # close but return False to mock like is still opened + data.close() + return False + # only if file really closed (by us) return True + return True -_unspecified = object() + data.isclosed = is_closed + return data class BaseResponse(object): @@ -259,11 +296,14 @@ stream = False - def __init__(self, method, url, match_querystring=_unspecified, match=()): + def __init__(self, method, url, match_querystring=None, match=()): self.method = method # ensure the url has a default path set if the url is a string self.url = _ensure_url_default_path(url) - self.match_querystring = self._should_match_querystring(match_querystring) + + if self._should_match_querystring(match_querystring): + match = tuple(match) + (_query_string_matcher(urlparse(self.url).query),) + self.match = match self.call_count = 0 @@ -285,40 +325,32 @@ def __ne__(self, other): return not self.__eq__(other) - def _url_matches_strict(self, url, other): - url_parsed = urlparse(url) - other_parsed = urlparse(other) - - if url_parsed[:3] != other_parsed[:3]: - return False - - url_qsl = sorted(parse_qsl(url_parsed.query)) - other_qsl = sorted(parse_qsl(other_parsed.query)) - - return url_qsl == other_qsl - def _should_match_querystring(self, match_querystring_argument): - if match_querystring_argument is not _unspecified: - return match_querystring_argument - if isinstance(self.url, Pattern): # the old default from <= 0.9.0 return False + if match_querystring_argument is not None: + warn( + ( + "Argument 'match_querystring' is deprecated. " + "Use 'responses.matchers.query_param_matcher' or " + "'responses.matchers.query_string_matcher'" + ), + DeprecationWarning, + ) + return match_querystring_argument + return bool(urlparse(self.url).query) - def _url_matches(self, url, other, match_querystring=False): + def _url_matches(self, url, other): if _is_string(url): if _has_unicode(url): url = _clean_unicode(url) if not isinstance(other, six.text_type): other = other.encode("ascii").decode("utf8") - if match_querystring: - normalize_url = parse_url(url).url - return self._url_matches_strict(normalize_url, other) - else: - return _get_url_and_path(url) == _get_url_and_path(other) + return _get_url_and_path(url) == _get_url_and_path(other) elif isinstance(url, Pattern) and url.match(other): return True @@ -350,7 +382,7 @@ if request.method != self.method: return False, "Method does not match" - if not self._url_matches(self.url, request.url, self.match_querystring): + if not self._url_matches(self.url, request.url): return False, "URL does not match" valid, reason = self._req_attr_matches(self.match, request) @@ -535,17 +567,35 @@ response_callback=None, passthru_prefixes=(), target="requests.adapters.HTTPAdapter.send", + registry=FirstMatchRegistry, ): self._calls = CallList() self.reset() + self._registry = registry() # call only after reset self.assert_all_requests_are_fired = assert_all_requests_are_fired self.response_callback = response_callback self.passthru_prefixes = tuple(passthru_prefixes) self.target = target + self._patcher = None + self._matches = [] + + def _get_registry(self): + return self._registry + + def _set_registry(self, new_registry): + if self.registered(): + err_msg = ( + "Cannot replace Registry, current registry has responses.\n" + "Run 'responses.registry.reset()' first" + ) + raise AttributeError(err_msg) + + self._registry = new_registry() def reset(self): - self._matches = [] + self._registry = FirstMatchRegistry() self._calls.reset() + self.passthru_prefixes = () def add( self, @@ -557,8 +607,9 @@ **kwargs ): """ - A basic request: + >>> import responses + A basic request: >>> responses.add(responses.GET, 'http://example.com') You can also directly pass an object which implements the @@ -582,23 +633,15 @@ >>> headers={'X-Header': 'foo'}, >>> ) - - Strict query string matching: - - >>> responses.add( - >>> method='GET', - >>> url='http://example.com?foo=bar', - >>> match_querystring=True - >>> ) """ if isinstance(method, BaseResponse): - self._matches.append(method) + self._registry.add(method) return if adding_headers is not None: kwargs.setdefault("headers", adding_headers) - self._matches.append(Response(method=method, url=url, body=body, **kwargs)) + self._registry.add(Response(method=method, url=url, body=body, **kwargs)) def add_passthru(self, prefix): """ @@ -607,6 +650,7 @@ For example, to allow any request to 'https://example.com', but require mocks for the remainder, you would add the prefix as so: + >>> import responses >>> responses.add_passthru('https://example.com') Regex can be used like: @@ -623,16 +667,16 @@ either by a response object inheriting ``BaseResponse`` or ``method`` and ``url``. Removes all matching responses. - >>> response.add(responses.GET, 'http://example.org') - >>> response.remove(responses.GET, 'http://example.org') + >>> import responses + >>> responses.add(responses.GET, 'http://example.org') + >>> responses.remove(responses.GET, 'http://example.org') """ if isinstance(method_or_response, BaseResponse): response = method_or_response else: response = BaseResponse(method=method_or_response, url=url) - while response in self._matches: - self._matches.remove(response) + self._registry.remove(response) def replace(self, method_or_response=None, url=None, body="", *args, **kwargs): """ @@ -640,6 +684,7 @@ is identical to ``add()``. The response is identified using ``method`` and ``url``, and the first matching response is replaced. + >>> import responses >>> responses.add(responses.GET, 'http://example.org', json={'data': 1}) >>> responses.replace(responses.GET, 'http://example.org', json={'data': 2}) """ @@ -649,11 +694,7 @@ else: response = Response(method=method_or_response, url=url, body=body, **kwargs) - try: - index = self._matches.index(response) - except ValueError: - raise ValueError("Response is not registered for URL %s" % url) - self._matches[index] = response + self._registry.replace(response) def upsert(self, method_or_response=None, url=None, body="", *args, **kwargs): """ @@ -661,6 +702,7 @@ if no response exists. Responses are matched using ``method``and ``url``. The first matching response is replaced. + >>> import responses >>> responses.add(responses.GET, 'http://example.org', json={'data': 1}) >>> responses.upsert(responses.GET, 'http://example.org', json={'data': 2}) """ @@ -681,7 +723,7 @@ # ensure the url has a default path set if the url is a string # url = _ensure_url_default_path(url, match_querystring) - self._matches.append( + self._registry.add( CallbackResponse( url=url, method=method, @@ -693,7 +735,7 @@ ) def registered(self): - return self._matches + return self._registry.registered @property def calls(self): @@ -709,8 +751,14 @@ self.reset() return success - def activate(self, func): - return get_wrapped(func, self) + def activate(self, func=None, registry=None): + if func is not None: + return get_wrapped(func, self) + + def deco_activate(func): + return get_wrapped(func, self, registry) + + return deco_activate def _find_match(self, request): """ @@ -721,21 +769,7 @@ (Response) found match. If multiple found, then remove & return the first match. (list) list with reasons why other matches don't match """ - found = None - found_match = None - match_failed_reasons = [] - for i, match in enumerate(self._matches): - match_result, reason = match.matches(request) - if match_result: - if found is None: - found = i - found_match = match - else: - # Multiple matches found. Remove & return the first match. - return self._matches.pop(found), match_failed_reasons - else: - match_failed_reasons.append(reason) - return found_match, match_failed_reasons + return self._registry.find(request) def _parse_request_params(self, url): params = {} @@ -774,7 +808,7 @@ "- %s %s\n\n" "Available matches:\n" % (request.method, request.url) ) - for i, m in enumerate(self._matches): + for i, m in enumerate(self.registered()): error_msg += "- {} {} {}\n".format( m.method, m.url, match_failed_reasons[i] ) @@ -798,11 +832,6 @@ response = resp_callback(response) if resp_callback else response raise - stream = kwargs.get("stream") - if not stream: - response.content # NOQA required to ensure that response body is read. - response.close() - response = resp_callback(response) if resp_callback else response match.call_count += 1 self._calls.add(request, response) @@ -823,7 +852,7 @@ if not allow_assert: return - not_called = [m for m in self._matches if m.call_count == 0] + not_called = [m for m in self.registered() if m.call_count == 0] if not_called: raise AssertionError( "Not all requests have been executed {0!r}".format( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/responses/__init__.pyi new/responses-0.17.0/responses/__init__.pyi --- old/responses-0.16.0/responses/__init__.pyi 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/responses/__init__.pyi 2022-01-07 17:18:13.000000000 +0100 @@ -23,6 +23,7 @@ from urllib.parse import quote as quote from urllib3.response import HTTPHeaderDict from .matchers import urlencoded_params_matcher, json_params_matcher +from .registries import FirstMatchRegistry def _clean_unicode(url: str) -> str: ... @@ -38,7 +39,7 @@ def _has_unicode(s: str) -> bool: ... def _is_string(s: Union[Pattern[str], str]) -> bool: ... def get_wrapped( - func: Callable[..., Any], responses: RequestsMock + func: Callable[..., Any], responses: RequestsMock, registry: Optional[Any] ) -> Callable[..., Any]: ... @@ -51,7 +52,9 @@ MatcherIterable = Iterable[Callable[[Any], Callable[..., Any]]] class CallList(Sequence[Call], Sized): - def __init__(self) -> None: ... + def __init__(self) -> None: + self._calls = List[Call] + ... def __iter__(self) -> Iterator[Call]: ... def __len__(self) -> int: ... def __getitem__(self, idx: int) -> Call: ... # type: ignore [override] @@ -78,7 +81,7 @@ def __eq__(self, other: Any) -> bool: ... def __ne__(self, other: Any) -> bool: ... def _req_attr_matches( - self, match: MatcherIterable, request: Optional[Union[bytes, str]] + self, match: MatcherIterable, request: PreparedRequest ) -> Tuple[bool, str]: ... def _should_match_querystring( self, match_querystring_argument: Union[bool, object] @@ -144,6 +147,8 @@ ) -> None: ... def isclosed(self) -> bool: ... +_F = TypeVar("_F", bound=Callable[..., Any]) + class RequestsMock: DELETE: Literal["DELETE"] GET: Literal["GET"] @@ -154,7 +159,7 @@ PUT: Literal["PUT"] response_callback: Optional[Callable[[Any], Any]] = ... assert_all_requests_are_fired: Any = ... - passthru_prefixes: Tuple[str, ...] = ... + passthru_prefixes: Tuple[Union[str, Pattern[str]], ...] = ... target: Any = ... _matches: List[Any] def __init__( @@ -163,7 +168,11 @@ response_callback: Optional[Callable[[Any], Any]] = ..., passthru_prefixes: Tuple[str, ...] = ..., target: str = ..., - ) -> None: ... + registry: Any = ..., + ) -> None: + self._patcher = Callable[[Any], Any] + self._calls = CallList + ... def reset(self) -> None: ... add: _Add add_passthru: _AddPassthru @@ -173,24 +182,23 @@ url: Optional[Union[Pattern[str], str]] = ..., ) -> None: ... replace: _Replace + upsert: _Upsert add_callback: _AddCallback @property def calls(self) -> CallList: ... def __enter__(self) -> RequestsMock: ... def __exit__(self, type: Any, value: Any, traceback: Any) -> bool: ... - activate: _Activate + def activate(self, func: Optional[_F], registry: Optional[Any]) -> _F: ... def start(self) -> None: ... def stop(self, allow_assert: bool = ...) -> None: ... def assert_call_count(self, url: str, count: int) -> bool: ... def registered(self) -> List[Any]: ... + def _set_registry(self, registry: Any) -> None: ... + def _get_registry(self) -> Any: ... -_F = TypeVar("_F", bound=Callable[..., Any]) HeaderSet = Optional[Union[Mapping[str, str], List[Tuple[str, str]]]] -class _Activate(Protocol): - def __call__(self, func: _F) -> _F: ... - class _Add(Protocol): def __call__( self, @@ -267,7 +275,7 @@ def __call__(self) -> List[Response]: ... -activate: _Activate +activate: Any add: _Add add_callback: _AddCallback add_passthru: _AddPassthru @@ -322,5 +330,5 @@ "start", "stop", "target", - "upsert" + "upsert", ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/responses/matchers.py new/responses-0.17.0/responses/matchers.py --- old/responses-0.16.0/responses/matchers.py 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/responses/matchers.py 2022-01-07 17:18:13.000000000 +0100 @@ -175,14 +175,16 @@ :param query: (str), same as constructed by request :return: (func) matcher """ + if six.PY2 and isinstance(query, unicode): # noqa: F821 + query = query.encode("utf8") def match(request): reason = "" data = parse_url(request.url) request_query = data.query - request_qsl = sorted(parse_qsl(request_query)) - matcher_qsl = sorted(parse_qsl(query)) + request_qsl = sorted(parse_qsl(request_query)) if request_query else {} + matcher_qsl = sorted(parse_qsl(query)) if query else {} valid = not query if request_query is None else request_qsl == matcher_qsl @@ -275,12 +277,12 @@ ) request_body = request.body - if isinstance(request_body, bytes): - request_body = request_body.decode("utf-8") - prepared_body = prepared.body + if isinstance(prepared_body, bytes): - prepared_body = prepared_body.decode("utf-8") + # since headers always come as str, need to convert to bytes + prepared_boundary = prepared_boundary.encode("utf-8") + request_boundary = request_boundary.encode("utf-8") prepared_body = prepared_body.replace(prepared_boundary, request_boundary) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/responses/registries.py new/responses-0.17.0/responses/registries.py --- old/responses-0.16.0/responses/registries.py 1970-01-01 01:00:00.000000000 +0100 +++ new/responses-0.17.0/responses/registries.py 2022-01-07 17:18:13.000000000 +0100 @@ -0,0 +1,48 @@ +class FirstMatchRegistry(object): + def __init__(self): + self._responses = [] + + @property + def registered(self): + return self._responses + + def reset(self): + self._responses = [] + + def find(self, request): + found = None + found_match = None + match_failed_reasons = [] + for i, response in enumerate(self.registered): + match_result, reason = response.matches(request) + if match_result: + if found is None: + found = i + found_match = response + else: + if self.registered[found].call_count > 0: + # that assumes that some responses were added between calls + self.registered.pop(found) + found_match = response + break + # Multiple matches found. Remove & return the first response. + return self.registered.pop(found), match_failed_reasons + else: + match_failed_reasons.append(reason) + return found_match, match_failed_reasons + + def add(self, response): + self.registered.append(response) + + def remove(self, response): + while response in self.registered: + self.registered.remove(response) + + def replace(self, response): + try: + index = self.registered.index(response) + except ValueError: + raise ValueError( + "Response is not registered for URL {}".format(response.url) + ) + self.registered[index] = response diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/responses/registries.pyi new/responses-0.17.0/responses/registries.pyi --- old/responses-0.16.0/responses/registries.pyi 1970-01-01 01:00:00.000000000 +0100 +++ new/responses-0.17.0/responses/registries.pyi 2022-01-07 17:18:13.000000000 +0100 @@ -0,0 +1,17 @@ +from typing import ( + List, + Tuple, +) +from requests.adapters import PreparedRequest +from responses import BaseResponse + +class FirstMatchRegistry: + _responses = List[BaseResponse] + def __init__(self) -> None: ... + @property + def registered(self) -> List[BaseResponse]: ... + def reset(self) -> None: ... + def find(self, request: PreparedRequest) -> Tuple[BaseResponse, List[str]]: ... + def add(self, response: BaseResponse) -> None: ... + def remove(self, response: BaseResponse) -> None: ... + def replace(self, response: BaseResponse) -> None: ... diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/responses/test_matchers.py new/responses-0.17.0/responses/test_matchers.py --- old/responses-0.16.0/responses/test_matchers.py 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/responses/test_matchers.py 2022-01-07 17:18:13.000000000 +0100 @@ -18,7 +18,7 @@ def assert_reset(): - assert len(responses._default_mock._matches) == 0 + assert len(responses._default_mock.registered()) == 0 assert len(responses.calls) == 0 @@ -283,17 +283,32 @@ assert_reset() -def test_multipart_matcher(): +@pytest.mark.parametrize( + "req_file,match_file", + [ + (b"Old World!", "Old World!"), + ("Old World!", b"Old World!"), + (b"Old World!", b"Old World!"), + ("Old World!", "Old World!"), + (b"\xacHello World!", b"\xacHello World!"), + ], +) +def test_multipart_matcher(req_file, match_file): @responses.activate def run(): req_data = {"some": "other", "data": "fields"} - req_files = {"file_name": b"Old World!"} responses.add( responses.POST, url="http://httpbin.org/post", - match=[matchers.multipart_matcher(req_files, data=req_data)], + match=[ + matchers.multipart_matcher( + files={"file_name": match_file}, data=req_data + ) + ], + ) + resp = requests.post( + "http://httpbin.org/post", data=req_data, files={"file_name": req_file} ) - resp = requests.post("http://httpbin.org/post", data=req_data, files=req_files) assert resp.status_code == 200 with pytest.raises(TypeError): @@ -334,14 +349,24 @@ msg = str(excinfo.value) assert "multipart/form-data doesn't match. Request body differs." in msg - assert ( - '\r\nContent-Disposition: form-data; name="file_name"; ' - 'filename="file_name"\r\n\r\nOld World!\r\n' - ) in msg - assert ( - '\r\nContent-Disposition: form-data; name="file_name"; ' - 'filename="file_name"\r\n\r\nNew World!\r\n' - ) in msg + if six.PY2: + assert ( + '\r\nContent-Disposition: form-data; name="file_name"; ' + 'filename="file_name"\r\n\r\nOld World!\r\n' + ) in msg + assert ( + '\r\nContent-Disposition: form-data; name="file_name"; ' + 'filename="file_name"\r\n\r\nNew World!\r\n' + ) in msg + else: + assert ( + r'\r\nContent-Disposition: form-data; name="file_name"; ' + r'filename="file_name"\r\n\r\nOld World!\r\n' + ) in msg + assert ( + r'\r\nContent-Disposition: form-data; name="file_name"; ' + r'filename="file_name"\r\n\r\nNew World!\r\n' + ) in msg # x-www-form-urlencoded request with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/responses/test_responses.py new/responses-0.17.0/responses/test_responses.py --- old/responses-0.16.0/responses/test_responses.py 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/responses/test_responses.py 2022-01-07 17:18:13.000000000 +0100 @@ -18,6 +18,7 @@ PassthroughResponse, matchers, CallbackResponse, + registries, ) @@ -28,7 +29,7 @@ def assert_reset(): - assert len(responses._default_mock._matches) == 0 + assert len(responses._default_mock.registered()) == 0 assert len(responses.calls) == 0 @@ -1070,24 +1071,24 @@ # check that assert_all_requests_are_fired=True doesn't remove urls with responses.RequestsMock(assert_all_requests_are_fired=True) as m: m.add(responses.GET, "http://example.com", body=b"test") - assert len(m._matches) == 1 + assert len(m.registered()) == 1 requests.get("http://example.com") - assert len(m._matches) == 1 + assert len(m.registered()) == 1 # check that assert_all_requests_are_fired=True counts mocked errors with responses.RequestsMock(assert_all_requests_are_fired=True) as m: m.add(responses.GET, "http://example.com", body=Exception()) - assert len(m._matches) == 1 + assert len(m.registered()) == 1 with pytest.raises(Exception): requests.get("http://example.com") - assert len(m._matches) == 1 + assert len(m.registered()) == 1 with responses.RequestsMock(assert_all_requests_are_fired=True) as m: m.add_callback(responses.GET, "http://example.com", request_callback) - assert len(m._matches) == 1 + assert len(m.registered()) == 1 with pytest.raises(BaseException): requests.get("http://example.com") - assert len(m._matches) == 1 + assert len(m.registered()) == 1 run() assert_reset() @@ -1219,6 +1220,9 @@ https://github.com/psf/requests/pull/3563 Now user can manually patch URL3 lib to achieve the same + + See discussion in + https://github.com/getsentry/responses/issues/394 """ @responses.activate @@ -1248,6 +1252,30 @@ assert_reset() +def test_stream_with_none_chunk_size(): + """ + See discussion in + https://github.com/getsentry/responses/issues/438 + """ + + @responses.activate + def run(): + responses.add( + responses.GET, + "https://example.com", + status=200, + content_type="application/octet-stream", + body=b"This is test", + auto_calculate_content_length=True, + ) + res = requests.get("https://example.com", stream=True) + for chunk in res.iter_content(chunk_size=None): + assert chunk == b"This is test" + + run() + assert_reset() + + def test_legacy_adding_headers(): @responses.activate def run(): @@ -1377,15 +1405,48 @@ def run(): responses.add(responses.GET, "http://example.com", body="test") responses.add(responses.GET, "http://example.com", body="rest") + responses.add(responses.GET, "http://example.com", body="fest") + responses.add(responses.GET, "http://example.com", body="best") resp = requests.get("http://example.com") assert_response(resp, "test") + resp = requests.get("http://example.com") assert_response(resp, "rest") + + resp = requests.get("http://example.com") + assert_response(resp, "fest") + + resp = requests.get("http://example.com") + assert_response(resp, "best") + # After all responses are used, last response should be repeated resp = requests.get("http://example.com") + assert_response(resp, "best") + + run() + assert_reset() + + +def test_multiple_responses_intermixed(): + @responses.activate + def run(): + responses.add(responses.GET, "http://example.com", body="test") + resp = requests.get("http://example.com") + assert_response(resp, "test") + + responses.add(responses.GET, "http://example.com", body="rest") + resp = requests.get("http://example.com") assert_response(resp, "rest") + responses.add(responses.GET, "http://example.com", body="best") + resp = requests.get("http://example.com") + assert_response(resp, "best") + + # After all responses are used, last response should be repeated + resp = requests.get("http://example.com") + assert_response(resp, "best") + run() assert_reset() @@ -1546,6 +1607,37 @@ assert_reset() +def test_passthru_does_not_persist_across_tests(httpserver): + """ + passthru should be erased on exit from context manager + see: + https://github.com/getsentry/responses/issues/322 + """ + httpserver.serve_content("OK", headers={"Content-Type": "text/plain"}) + + @responses.activate + def with_a_passthru(): + assert not responses._default_mock.passthru_prefixes + responses.add_passthru(re.compile(".*")) + try: + response = requests.get("https://example.com") + except ConnectionError as err: + if "Failed to establish" in str(err): + pytest.skip("Cannot resolve DNS for example.com") + raise err + + assert response.status_code == 200 + + @responses.activate + def without_a_passthru(): + assert not responses._default_mock.passthru_prefixes + with pytest.raises(requests.exceptions.ConnectionError): + requests.get("https://example.com") + + with_a_passthru() + without_a_passthru() + + def test_method_named_param(): @responses.activate def run(): @@ -1767,7 +1859,7 @@ mocks_list = responses.registered() - assert mocks_list == responses.mock._matches + assert mocks_list == responses.mock.registered() assert mocks_list == [first_response, second_response, third_response] run() @@ -1792,3 +1884,86 @@ run() assert_reset() + + +def test_set_registry_not_empty(): + class CustomRegistry(registries.FirstMatchRegistry): + pass + + @responses.activate + def run(): + url = "http://fizzbuzz/foo" + responses.add(method=responses.GET, url=url) + with pytest.raises(AttributeError) as excinfo: + responses.mock._set_registry(CustomRegistry) + msg = str(excinfo.value) + assert "Cannot replace Registry, current registry has responses" in msg + + run() + assert_reset() + + +def test_set_registry(): + class CustomRegistry(registries.FirstMatchRegistry): + pass + + @responses.activate(registry=CustomRegistry) + def run_with_registry(): + assert type(responses.mock._get_registry()) == CustomRegistry + + @responses.activate + def run(): + # test that registry does not leak to another test + assert type(responses.mock._get_registry()) == registries.FirstMatchRegistry + + run_with_registry() + run() + assert_reset() + + +def test_set_registry_context_manager(): + def run(): + class CustomRegistry(registries.FirstMatchRegistry): + pass + + with responses.RequestsMock( + assert_all_requests_are_fired=False, registry=CustomRegistry + ) as rsps: + assert type(rsps._get_registry()) == CustomRegistry + assert type(responses.mock._get_registry()) == registries.FirstMatchRegistry + + run() + assert_reset() + + +def test_registry_reset(): + def run(): + class CustomRegistry(registries.FirstMatchRegistry): + pass + + with responses.RequestsMock( + assert_all_requests_are_fired=False, registry=CustomRegistry + ) as rsps: + rsps._get_registry().reset() + assert not rsps.registered() + + run() + assert_reset() + + +def test_requests_between_add(): + @responses.activate + def run(): + responses.add(responses.GET, "https://example.com/", json={"response": "old"}) + assert requests.get("https://example.com/").content == b'{"response": "old"}' + assert requests.get("https://example.com/").content == b'{"response": "old"}' + assert requests.get("https://example.com/").content == b'{"response": "old"}' + + responses.add(responses.GET, "https://example.com/", json={"response": "new"}) + + assert requests.get("https://example.com/").content == b'{"response": "new"}' + assert requests.get("https://example.com/").content == b'{"response": "new"}' + assert requests.get("https://example.com/").content == b'{"response": "new"}' + + run() + assert_reset() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/responses.egg-info/PKG-INFO new/responses-0.17.0/responses.egg-info/PKG-INFO --- old/responses-0.16.0/responses.egg-info/PKG-INFO 2021-11-11 19:03:24.000000000 +0100 +++ new/responses-0.17.0/responses.egg-info/PKG-INFO 2022-01-07 17:18:16.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: responses -Version: 0.16.0 +Version: 0.17.0 Summary: A utility library for mocking out the `requests` Python library. Home-page: https://github.com/getsentry/responses Author: David Cramer @@ -129,6 +129,9 @@ The full resource URL. match_querystring (``bool``) + DEPRECATED: Use `responses.matchers.query_param_matcher` or + `responses.matchers.query_string_matcher` + Include the query string when matching requests. Enabled by default if the response URL contains a query string, disabled if it doesn't or the URL is a regular expression. @@ -294,7 +297,7 @@ req_files = {"file_name": b"Old World!"} responses.add( responses.POST, url="http://httpbin.org/post", - match=[multipart_matcher(req_data, req_files)] + match=[multipart_matcher(req_files, data=req_data)] ) resp = requests.post("http://httpbin.org/post", files={"file_name": b"New World!"}) @@ -411,6 +414,49 @@ resp = session.send(prepped) assert resp.text == "hello world" +Response Registry +--------------------------- + +By default, ``responses`` will search all registered``Response`` objects and +return a match. If only one ``Response`` is registered, the registry is kept unchanged. +However, if multiple matches are found for the same request, then first match is returned and +removed from registry. + +Such behavior is suitable for most of use cases, but to handle special conditions, you can +implement custom registry which must follow interface of ``registries.FirstMatchRegistry``. +Redefining the ``find`` method will allow you to create custom search logic and return +appropriate ``Response`` + +Example that shows how to set custom registry + +.. code-block:: python + + import responses + from responses import registries + + + class CustomRegistry(registries.FirstMatchRegistry): + pass + + + """ Before tests: <responses.registries.FirstMatchRegistry object> """ + + # using function decorator + @responses.activate(registry=CustomRegistry) + def run(): + """ Within test: <__main__.CustomRegistry object> """ + + run() + """ After test: <responses.registries.FirstMatchRegistry object> """ + + # using context manager + with responses.RequestsMock(registry=CustomRegistry) as rsps: + """ In context manager: <__main__.CustomRegistry object> """ + + """ + After exit from context manager: <responses.registries.FirstMatchRegistry object> + """ + Dynamic Responses ----------------- @@ -590,22 +636,26 @@ .. code-block:: python - def setUp(): - self.responses = responses.RequestsMock() - self.responses.start() - - # self.responses.add(...) - - self.addCleanup(self.responses.stop) - self.addCleanup(self.responses.reset) + class TestMyApi(unittest.TestCase): + def setUp(self): + responses.add(responses.GET, 'https://example.com', body="within setup") + # here go other self.responses.add(...) + + @responses.activate + def test_my_func(self): + responses.add( + responses.GET, + "https://httpbin.org/get", + match=[matchers.query_param_matcher({"test": "1", "didi": "pro"})], + body="within test" + ) + resp = requests.get("https://example.com") + resp2 = requests.get("https://httpbin.org/get", params={"test": "1", "didi": "pro"}) + print(resp.text) + # >>> within setup + print(resp2.text) + # >>> within test - def test_api(self): - self.responses.add( - responses.GET, 'http://twitter.com/api/1/foobar', - body='{}', status=200, - content_type='application/json') - resp = requests.get('http://twitter.com/api/1/foobar') - assert resp.status_code == 200 Assertions on declared responses -------------------------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/responses.egg-info/SOURCES.txt new/responses-0.17.0/responses.egg-info/SOURCES.txt --- old/responses-0.16.0/responses.egg-info/SOURCES.txt 2021-11-11 19:03:24.000000000 +0100 +++ new/responses-0.17.0/responses.egg-info/SOURCES.txt 2022-01-07 17:18:16.000000000 +0100 @@ -9,6 +9,8 @@ responses/__init__.pyi responses/matchers.py responses/matchers.pyi +responses/registries.py +responses/registries.pyi responses/test_matchers.py responses/test_responses.py responses.egg-info/PKG-INFO diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/setup.py new/responses-0.17.0/setup.py --- old/responses-0.16.0/setup.py 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/setup.py 2022-01-07 17:18:13.000000000 +0100 @@ -59,7 +59,7 @@ setup( name="responses", - version="0.16.0", + version="0.17.0", author="David Cramer", description=("A utility library for mocking out the `requests` Python library."), url="https://github.com/getsentry/responses", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/responses-0.16.0/tox.ini new/responses-0.17.0/tox.ini --- old/responses-0.16.0/tox.ini 2021-11-11 19:03:18.000000000 +0100 +++ new/responses-0.17.0/tox.ini 2022-01-07 17:18:13.000000000 +0100 @@ -1,5 +1,5 @@ [tox] -envlist = py27,py35,py36,py37,py38,py39,py310,mypy +envlist = py27,py35,py36,py37,py38,py39,py310,mypy,precom [testenv] extras = tests @@ -12,3 +12,10 @@ basepython = python3.7 commands = python -m mypy --config-file=mypy.ini -p responses + +[testenv:precom] +description = Run pre-commit hooks (black, flake, etc) +basepython = python3.7 +deps = pre-commit +commands = + pre-commit run --all-files