From ee1c6179477d9151dfb05e87c659eb2fa81628d1 Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 14 Apr 2022 15:20:29 +0200 Subject: [PATCH 01/15] added responses recorder --- responses/_recorder.py | 98 +++++++++++++++++++++++++++++++++++++++++ responses/registries.py | 36 +++++++++++---- 2 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 responses/_recorder.py diff --git a/responses/_recorder.py b/responses/_recorder.py new file mode 100644 index 00000000..9058bf6c --- /dev/null +++ b/responses/_recorder.py @@ -0,0 +1,98 @@ +from functools import wraps +from itertools import groupby +from unittest import mock as std_mock +from urllib.parse import parse_qsl +from urllib.parse import urlsplit + +from responses import Response +from responses import _real_send +from responses.registries import OrderedRegistry + + +class Recorder(object): + def __init__( + self, target="requests.adapters.HTTPAdapter.send", registry=OrderedRegistry + ): + self.target = target + self._patcher = None + self._registry = registry() + + def reset(self): + self.get_registry().reset() + + def __enter__(self): + self.start() + return self + + def __exit__(self, type, value, traceback): + success = type is None + self.stop() + self.reset() + return success + + def get_registry(self): + return self._registry + + def record(self, func=None, *, file_path=None): + def deco_activate(function): + @wraps(function) + def wrapper(*args, **kwargs): + with self: + ret = function(*args, **kwargs) + with open(file_path, "w") as file: + self.get_registry()._dump(file) + + return ret + + return wrapper + + return deco_activate + + def _parse_request_params(self, url): + params = {} + for key, val in groupby(parse_qsl(urlsplit(url).query), lambda kv: kv[0]): + values = list(map(lambda x: x[1], val)) + if len(values) == 1: + values = values[0] + params[key] = values + return params + + def _on_request(self, adapter, request, **kwargs): + # add attributes params and req_kwargs to 'request' object for further match comparison + # original request object does not have these attributes + request.params = self._parse_request_params(request.path_url) + request.req_kwargs = kwargs + requests_response = _real_send(adapter, request, **kwargs) + responses_response = Response( + method=request.method, + url=requests_response.request.url, + status=requests_response.status_code, + ) + self._registry.add(responses_response) + return requests_response + + def start(self): + if self._patcher: + # we must not override value of the _patcher if already applied + # this prevents issues when one decorated function is called from + # another decorated function + return + + def unbound_on_send(adapter, request, *a, **kwargs): + return self._on_request(adapter, request, *a, **kwargs) + + self._patcher = std_mock.patch(target=self.target, new=unbound_on_send) + self._patcher.start() + + def stop(self): + if self._patcher: + # prevent stopping unstarted patchers + self._patcher.stop() + + # once patcher is stopped, clean it. This is required to create a new + # fresh patcher on self.start() + self._patcher = None + + +recorder = Recorder() +record = recorder.record diff --git a/responses/registries.py b/responses/registries.py index 049df23f..bc890e91 100644 --- a/responses/registries.py +++ b/responses/registries.py @@ -8,15 +8,15 @@ # import only for linter run from requests import PreparedRequest - from responses import BaseResponse + from responses import Response class FirstMatchRegistry(object): def __init__(self) -> None: - self._responses: List["BaseResponse"] = [] + self._responses: List["Response"] = [] @property - def registered(self) -> List["BaseResponse"]: + def registered(self) -> List["Response"]: return self._responses def reset(self) -> None: @@ -24,7 +24,7 @@ def reset(self) -> None: def find( self, request: "PreparedRequest" - ) -> Tuple[Optional["BaseResponse"], List[str]]: + ) -> Tuple[Optional["Response"], List[str]]: found = None found_match = None match_failed_reasons = [] @@ -46,7 +46,7 @@ def find( match_failed_reasons.append(reason) return found_match, match_failed_reasons - def add(self, response: "BaseResponse") -> "BaseResponse": + def add(self, response: "Response") -> "Response": if any(response is resp for resp in self.registered): # if user adds multiple responses that reference the same instance. # do a comparison by memory allocation address. @@ -56,14 +56,14 @@ def add(self, response: "BaseResponse") -> "BaseResponse": self.registered.append(response) return response - def remove(self, response: "BaseResponse") -> List["BaseResponse"]: + def remove(self, response: "Response") -> List["Response"]: removed_responses = [] while response in self.registered: self.registered.remove(response) removed_responses.append(response) return removed_responses - def replace(self, response: "BaseResponse") -> "BaseResponse": + def replace(self, response: "Response") -> "Response": try: index = self.registered.index(response) except ValueError: @@ -73,11 +73,31 @@ def replace(self, response: "BaseResponse") -> "BaseResponse": self.registered[index] = response return response + def _dump(self, destination): + import yaml + + data = {"responses": []} + for rsp in self.registered: + data["responses"].append( + { + "response": { + "method": rsp.method, + "url": rsp.url, + "body": rsp.body, + "status": rsp.status, + "headers": rsp.headers, + "content_type": rsp.content_type, + "auto_calculate_content_length": rsp.auto_calculate_content_length, + } + } + ) + yaml.dump(data, destination) + class OrderedRegistry(FirstMatchRegistry): def find( self, request: "PreparedRequest" - ) -> Tuple[Optional["BaseResponse"], List[str]]: + ) -> Tuple[Optional["Response"], List[str]]: if not self.registered: return None, ["No more registered responses"] From 46f42ea16939ff00fe97447b8d4108ec9a67d06d Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 14 Apr 2022 15:25:14 +0200 Subject: [PATCH 02/15] changelist --- CHANGES | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES b/CHANGES index fbf8ddb0..4e14a2a4 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,8 @@ +0.22.0 +------ + +* [BETA] Added possibility to record responses to YAML files via `@_recorder.record(file_path="out.yml")` decorator. + 0.21.0 ------ From ba58124222fa2311512206b6ced9cf8839eab998 Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 14 Apr 2022 15:28:10 +0200 Subject: [PATCH 03/15] clean up --- responses/_recorder.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/responses/_recorder.py b/responses/_recorder.py index 9058bf6c..072fea79 100644 --- a/responses/_recorder.py +++ b/responses/_recorder.py @@ -9,7 +9,7 @@ from responses.registries import OrderedRegistry -class Recorder(object): +class Recorder: def __init__( self, target="requests.adapters.HTTPAdapter.send", registry=OrderedRegistry ): @@ -33,8 +33,8 @@ def __exit__(self, type, value, traceback): def get_registry(self): return self._registry - def record(self, func=None, *, file_path=None): - def deco_activate(function): + def record(self, *, file_path=None): + def deco_record(function): @wraps(function) def wrapper(*args, **kwargs): with self: @@ -46,7 +46,7 @@ def wrapper(*args, **kwargs): return wrapper - return deco_activate + return deco_record def _parse_request_params(self, url): params = {} From 15710b55ddd87c19ca09f6f448c107e4e881f049 Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 23 Jun 2022 13:53:17 +0200 Subject: [PATCH 04/15] simplify recorder --- responses/_recorder.py | 46 ++++++------------------------------------ 1 file changed, 6 insertions(+), 40 deletions(-) diff --git a/responses/_recorder.py b/responses/_recorder.py index 072fea79..d02e0619 100644 --- a/responses/_recorder.py +++ b/responses/_recorder.py @@ -1,37 +1,22 @@ from functools import wraps from itertools import groupby -from unittest import mock as std_mock from urllib.parse import parse_qsl from urllib.parse import urlsplit +from responses import RequestsMock from responses import Response from responses import _real_send from responses.registries import OrderedRegistry -class Recorder: +class Recorder(RequestsMock): def __init__( self, target="requests.adapters.HTTPAdapter.send", registry=OrderedRegistry ): - self.target = target - self._patcher = None - self._registry = registry() + super().__init__(target=target, registry=registry) def reset(self): - self.get_registry().reset() - - def __enter__(self): - self.start() - return self - - def __exit__(self, type, value, traceback): - success = type is None - self.stop() - self.reset() - return success - - def get_registry(self): - return self._registry + self._registry = OrderedRegistry() def record(self, *, file_path=None): def deco_record(function): @@ -71,27 +56,8 @@ def _on_request(self, adapter, request, **kwargs): self._registry.add(responses_response) return requests_response - def start(self): - if self._patcher: - # we must not override value of the _patcher if already applied - # this prevents issues when one decorated function is called from - # another decorated function - return - - def unbound_on_send(adapter, request, *a, **kwargs): - return self._on_request(adapter, request, *a, **kwargs) - - self._patcher = std_mock.patch(target=self.target, new=unbound_on_send) - self._patcher.start() - - def stop(self): - if self._patcher: - # prevent stopping unstarted patchers - self._patcher.stop() - - # once patcher is stopped, clean it. This is required to create a new - # fresh patcher on self.start() - self._patcher = None + def stop(self, **kwargs): + super().stop(allow_assert=False) recorder = Recorder() From 0d3944ee36ea4c2f856fb3870d5c61f5ea150ce2 Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 23 Jun 2022 15:23:52 +0200 Subject: [PATCH 05/15] simplify recorder added types added "toml" dependence --- responses/_recorder.py | 42 +++++++++++++++++++++++------------------ responses/registries.py | 14 +++++++++----- setup.py | 2 ++ 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/responses/_recorder.py b/responses/_recorder.py index d02e0619..2c007c72 100644 --- a/responses/_recorder.py +++ b/responses/_recorder.py @@ -1,7 +1,15 @@ from functools import wraps -from itertools import groupby -from urllib.parse import parse_qsl -from urllib.parse import urlsplit +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + from typing import Type + from typing import Union + import os + from responses import FirstMatchRegistry + from responses import HTTPAdapter + from responses import PreparedRequest + from responses import models from responses import RequestsMock from responses import Response @@ -11,14 +19,16 @@ class Recorder(RequestsMock): def __init__( - self, target="requests.adapters.HTTPAdapter.send", registry=OrderedRegistry - ): + self, + target: str = "requests.adapters.HTTPAdapter.send", + registry: "Type[FirstMatchRegistry]" = OrderedRegistry, + ) -> None: super().__init__(target=target, registry=registry) - def reset(self): + def reset(self) -> None: self._registry = OrderedRegistry() - def record(self, *, file_path=None): + def record(self, *, file_path: "Union[str, bytes, os.PathLike]" = None): def deco_record(function): @wraps(function) def wrapper(*args, **kwargs): @@ -33,16 +43,12 @@ def wrapper(*args, **kwargs): return deco_record - def _parse_request_params(self, url): - params = {} - for key, val in groupby(parse_qsl(urlsplit(url).query), lambda kv: kv[0]): - values = list(map(lambda x: x[1], val)) - if len(values) == 1: - values = values[0] - params[key] = values - return params - - def _on_request(self, adapter, request, **kwargs): + def _on_request( + self, + adapter: "HTTPAdapter", + request: "PreparedRequest", + **kwargs: "Any", + ) -> "models.Response": # add attributes params and req_kwargs to 'request' object for further match comparison # original request object does not have these attributes request.params = self._parse_request_params(request.path_url) @@ -56,7 +62,7 @@ def _on_request(self, adapter, request, **kwargs): self._registry.add(responses_response) return requests_response - def stop(self, **kwargs): + def stop(self, **kwargs: "Any") -> None: super().stop(allow_assert=False) diff --git a/responses/registries.py b/responses/registries.py index bc890e91..957b1733 100644 --- a/responses/registries.py +++ b/responses/registries.py @@ -1,11 +1,17 @@ import copy from typing import TYPE_CHECKING +from typing import Any +from typing import Dict from typing import List from typing import Optional from typing import Tuple +import toml as _toml + if TYPE_CHECKING: # pragma: no cover # import only for linter run + import io + from requests import PreparedRequest from responses import Response @@ -73,10 +79,8 @@ def replace(self, response: "Response") -> "Response": self.registered[index] = response return response - def _dump(self, destination): - import yaml - - data = {"responses": []} + def _dump(self, destination: "io.IOBase") -> None: + data: Dict[str, Any] = {"responses": []} for rsp in self.registered: data["responses"].append( { @@ -91,7 +95,7 @@ def _dump(self, destination): } } ) - yaml.dump(data, destination) + _toml.dump(data, destination) class OrderedRegistry(FirstMatchRegistry): diff --git a/setup.py b/setup.py index 33b3bef1..d54ee09b 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,8 @@ install_requires = [ "requests>=2.0,<3.0", "urllib3>=1.25.10", + "toml", + "types-toml", "typing_extensions; python_version < '3.8'", ] From 5b69002f9942e550e87bd40b12bb44babf374ff0 Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 23 Jun 2022 15:35:16 +0200 Subject: [PATCH 06/15] fix some mypy issues --- responses/_recorder.py | 20 ++++++++++++-------- responses/registries.py | 20 ++++++++++---------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/responses/_recorder.py b/responses/_recorder.py index 2c007c72..8f1e1410 100644 --- a/responses/_recorder.py +++ b/responses/_recorder.py @@ -3,6 +3,7 @@ if TYPE_CHECKING: from typing import Any + from typing import Callable from typing import Type from typing import Union import os @@ -10,6 +11,7 @@ from responses import HTTPAdapter from responses import PreparedRequest from responses import models + from responses import _F from responses import RequestsMock from responses import Response @@ -28,10 +30,12 @@ def __init__( def reset(self) -> None: self._registry = OrderedRegistry() - def record(self, *, file_path: "Union[str, bytes, os.PathLike]" = None): - def deco_record(function): + def record( + self, *, file_path: "Union[str, bytes, os.PathLike]" = "response.toml" + ) -> "Union[Callable[[_F], _F], _F]": + def deco_record(function: "_F") -> "Callable[..., Any]": @wraps(function) - def wrapper(*args, **kwargs): + def wrapper(*args: "Any", **kwargs: "Any") -> "Any": # type: ignore[misc] with self: ret = function(*args, **kwargs) with open(file_path, "w") as file: @@ -51,18 +55,18 @@ def _on_request( ) -> "models.Response": # add attributes params and req_kwargs to 'request' object for further match comparison # original request object does not have these attributes - request.params = self._parse_request_params(request.path_url) - request.req_kwargs = kwargs + request.params = self._parse_request_params(request.path_url) # type: ignore[attr-defined] + request.req_kwargs = kwargs # type: ignore[attr-defined] requests_response = _real_send(adapter, request, **kwargs) responses_response = Response( - method=request.method, - url=requests_response.request.url, + method=str(request.method), + url=str(requests_response.request.url), status=requests_response.status_code, ) self._registry.add(responses_response) return requests_response - def stop(self, **kwargs: "Any") -> None: + def stop(self, allow_assert: bool = True) -> None: super().stop(allow_assert=False) diff --git a/responses/registries.py b/responses/registries.py index 957b1733..6907bf3f 100644 --- a/responses/registries.py +++ b/responses/registries.py @@ -14,15 +14,15 @@ from requests import PreparedRequest - from responses import Response + from responses import BaseResponse class FirstMatchRegistry(object): def __init__(self) -> None: - self._responses: List["Response"] = [] + self._responses: List["BaseResponse"] = [] @property - def registered(self) -> List["Response"]: + def registered(self) -> List["BaseResponse"]: return self._responses def reset(self) -> None: @@ -30,7 +30,7 @@ def reset(self) -> None: def find( self, request: "PreparedRequest" - ) -> Tuple[Optional["Response"], List[str]]: + ) -> Tuple[Optional["BaseResponse"], List[str]]: found = None found_match = None match_failed_reasons = [] @@ -52,7 +52,7 @@ def find( match_failed_reasons.append(reason) return found_match, match_failed_reasons - def add(self, response: "Response") -> "Response": + def add(self, response: "BaseResponse") -> "BaseResponse": if any(response is resp for resp in self.registered): # if user adds multiple responses that reference the same instance. # do a comparison by memory allocation address. @@ -62,19 +62,19 @@ def add(self, response: "Response") -> "Response": self.registered.append(response) return response - def remove(self, response: "Response") -> List["Response"]: + def remove(self, response: "BaseResponse") -> List["BaseResponse"]: removed_responses = [] while response in self.registered: self.registered.remove(response) removed_responses.append(response) return removed_responses - def replace(self, response: "Response") -> "Response": + def replace(self, response: "BaseResponse") -> "BaseResponse": try: index = self.registered.index(response) except ValueError: raise ValueError( - "Response is not registered for URL {}".format(response.url) + "BaseResponse is not registered for URL {}".format(response.url) ) self.registered[index] = response return response @@ -101,7 +101,7 @@ def _dump(self, destination: "io.IOBase") -> None: class OrderedRegistry(FirstMatchRegistry): def find( self, request: "PreparedRequest" - ) -> Tuple[Optional["Response"], List[str]]: + ) -> Tuple[Optional["BaseResponse"], List[str]]: if not self.registered: return None, ["No more registered responses"] @@ -112,7 +112,7 @@ def find( self.reset() self.add(response) reason = ( - "Next 'Response' in the order doesn't match " + "Next 'BaseResponse' in the order doesn't match " f"due to the following reason: {reason}." ) return None, [reason] From 0b629095144672553453ca8b35c249a7765836d7 Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 23 Jun 2022 16:04:42 +0200 Subject: [PATCH 07/15] type annotations --- responses/_recorder.py | 2 +- responses/registries.py | 31 +++++++++++++++++++------------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/responses/_recorder.py b/responses/_recorder.py index 8f1e1410..c5426542 100644 --- a/responses/_recorder.py +++ b/responses/_recorder.py @@ -31,7 +31,7 @@ def reset(self) -> None: self._registry = OrderedRegistry() def record( - self, *, file_path: "Union[str, bytes, os.PathLike]" = "response.toml" + self, *, file_path: "Union[str, bytes, os.PathLike[Any]]" = "response.toml" ) -> "Union[Callable[[_F], _F], _F]": def deco_record(function: "_F") -> "Callable[..., Any]": @wraps(function) diff --git a/responses/registries.py b/responses/registries.py index 6907bf3f..8b782001 100644 --- a/responses/registries.py +++ b/responses/registries.py @@ -82,19 +82,26 @@ def replace(self, response: "BaseResponse") -> "BaseResponse": def _dump(self, destination: "io.IOBase") -> None: data: Dict[str, Any] = {"responses": []} for rsp in self.registered: - data["responses"].append( - { - "response": { - "method": rsp.method, - "url": rsp.url, - "body": rsp.body, - "status": rsp.status, - "headers": rsp.headers, - "content_type": rsp.content_type, - "auto_calculate_content_length": rsp.auto_calculate_content_length, + try: + content_length = rsp.auto_calculate_content_length # type: ignore[attr-defined] + data["responses"].append( + { + "response": { + "method": rsp.method, + "url": rsp.url, + "body": rsp.body, # type: ignore[attr-defined] + "status": rsp.status, # type: ignore[attr-defined] + "headers": rsp.headers, + "content_type": rsp.content_type, + "auto_calculate_content_length": content_length, + } } - } - ) + ) + except AttributeError as exc: + raise AttributeError( + "Cannot dump response object." + "Probably you use custom Response object that misses required aatributes" + ) from exc _toml.dump(data, destination) From e8e7db4496a8ffc58c047d8808675a99fde5cf9b Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 23 Jun 2022 16:05:43 +0200 Subject: [PATCH 08/15] type annotations --- responses/registries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/responses/registries.py b/responses/registries.py index 8b782001..fbfbe1ba 100644 --- a/responses/registries.py +++ b/responses/registries.py @@ -74,7 +74,7 @@ def replace(self, response: "BaseResponse") -> "BaseResponse": index = self.registered.index(response) except ValueError: raise ValueError( - "BaseResponse is not registered for URL {}".format(response.url) + "Response is not registered for URL {}".format(response.url) ) self.registered[index] = response return response From 9a77d68ac03b0c2f3fff42d439168b63b14a08a8 Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 23 Jun 2022 16:06:21 +0200 Subject: [PATCH 09/15] type annotations --- responses/registries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/responses/registries.py b/responses/registries.py index fbfbe1ba..905a3c48 100644 --- a/responses/registries.py +++ b/responses/registries.py @@ -119,7 +119,7 @@ def find( self.reset() self.add(response) reason = ( - "Next 'BaseResponse' in the order doesn't match " + "Next 'Response' in the order doesn't match " f"due to the following reason: {reason}." ) return None, [reason] From 712916806c5de32269f1222633718b02d3ad4b7e Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 23 Jun 2022 16:29:52 +0200 Subject: [PATCH 10/15] added body --- responses/_recorder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/responses/_recorder.py b/responses/_recorder.py index c5426542..407a2ec7 100644 --- a/responses/_recorder.py +++ b/responses/_recorder.py @@ -62,6 +62,7 @@ def _on_request( method=str(request.method), url=str(requests_response.request.url), status=requests_response.status_code, + body=requests_response.text, ) self._registry.add(responses_response) return requests_response From a0031b4acbeea92d25ca9eb7786318b489424cbb Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Mon, 4 Jul 2022 12:00:41 +0200 Subject: [PATCH 11/15] Update responses/registries.py Co-authored-by: Mark Story --- responses/registries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/responses/registries.py b/responses/registries.py index 905a3c48..3f6ad7c3 100644 --- a/responses/registries.py +++ b/responses/registries.py @@ -100,7 +100,7 @@ def _dump(self, destination: "io.IOBase") -> None: except AttributeError as exc: raise AttributeError( "Cannot dump response object." - "Probably you use custom Response object that misses required aatributes" + "Probably you use custom Response object that is missing required attributes" ) from exc _toml.dump(data, destination) From c4e59a318277c42a406c4a14bb9859dd3ba0481f Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Mon, 11 Jul 2022 20:12:49 +0200 Subject: [PATCH 12/15] CHANGES --- CHANGES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 337f88e9..5553bdd4 100644 --- a/CHANGES +++ b/CHANGES @@ -1,7 +1,7 @@ 0.22.0 ------ -* [BETA] Added possibility to record responses to TOML files via `@_recorder.record(file_path="out.yml")` decorator. +* [BETA] Added possibility to record responses to TOML files via `@_recorder.record(file_path="out.toml")` decorator. * Fix type for the `mock`'s patcher object. See #556 * Add `passthrough` argument to `BaseResponse` object. See #557 * Fix `registries` leak. See #563 From 3abd8418d2ad29614d0b9cc85569c67c46f4cd56 Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Mon, 18 Jul 2022 16:50:44 +0200 Subject: [PATCH 13/15] added tests --- responses/_recorder.py | 2 +- responses/registries.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/responses/_recorder.py b/responses/_recorder.py index 407a2ec7..097a5725 100644 --- a/responses/_recorder.py +++ b/responses/_recorder.py @@ -1,7 +1,7 @@ from functools import wraps from typing import TYPE_CHECKING -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from typing import Any from typing import Callable from typing import Type diff --git a/responses/registries.py b/responses/registries.py index 3f6ad7c3..1e80a36c 100644 --- a/responses/registries.py +++ b/responses/registries.py @@ -97,7 +97,7 @@ def _dump(self, destination: "io.IOBase") -> None: } } ) - except AttributeError as exc: + except AttributeError as exc: # pragma: no cover raise AttributeError( "Cannot dump response object." "Probably you use custom Response object that is missing required attributes" From 1e75eaeec476a0c801fc4f59b1745870a7c6c886 Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Mon, 18 Jul 2022 16:51:13 +0200 Subject: [PATCH 14/15] added tests --- responses/tests/test_recorder.py | 94 ++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 responses/tests/test_recorder.py diff --git a/responses/tests/test_recorder.py b/responses/tests/test_recorder.py new file mode 100644 index 00000000..0d733793 --- /dev/null +++ b/responses/tests/test_recorder.py @@ -0,0 +1,94 @@ +from pathlib import Path + +import requests +import toml + +from responses import _recorder + + +class TestRecord: + def setup(self): + self.out_file = Path("out.toml") + if self.out_file.exists(): + self.out_file.unlink() # pragma: no cover + + assert not self.out_file.exists() + + def test_recorder(self, httpserver): + + httpserver.expect_request("/500").respond_with_data( + "500 Internal Server Error", status=500, content_type="text/plain" + ) + httpserver.expect_request("/202").respond_with_data( + "OK", status=202, content_type="text/plain" + ) + httpserver.expect_request("/404").respond_with_data( + "404 Not Found", status=404, content_type="text/plain" + ) + httpserver.expect_request("/status/wrong").respond_with_data( + "Invalid status code", status=400, content_type="text/plain" + ) + url500 = httpserver.url_for("/500") + url202 = httpserver.url_for("/202") + url404 = httpserver.url_for("/404") + url400 = httpserver.url_for("/status/wrong") + + def another(): + requests.get(url500) + requests.get(url202) + + @_recorder.record(file_path=self.out_file) + def run(): + requests.get(url404) + requests.get(url400) + another() + + run() + + with open(self.out_file) as file: + data = toml.load(file) + + assert data == { + "responses": [ + { + "response": { + "method": "GET", + "url": f"http://{httpserver.host}:{httpserver.port}/404", + "body": "404 Not Found", + "status": 404, + "content_type": "text/plain", + "auto_calculate_content_length": False, + } + }, + { + "response": { + "method": "GET", + "url": f"http://{httpserver.host}:{httpserver.port}/status/wrong", + "body": "Invalid status code", + "status": 400, + "content_type": "text/plain", + "auto_calculate_content_length": False, + } + }, + { + "response": { + "method": "GET", + "url": f"http://{httpserver.host}:{httpserver.port}/500", + "body": "500 Internal Server Error", + "status": 500, + "content_type": "text/plain", + "auto_calculate_content_length": False, + } + }, + { + "response": { + "method": "GET", + "url": f"http://{httpserver.host}:{httpserver.port}/202", + "body": "OK", + "status": 202, + "content_type": "text/plain", + "auto_calculate_content_length": False, + } + }, + ] + } From f7f937e89f4650a58472be7fd41143a30de2ba8d Mon Sep 17 00:00:00 2001 From: Maksim Beliaev Date: Thu, 18 Aug 2022 20:07:18 +0200 Subject: [PATCH 15/15] move dumping to the recorder --- responses/_recorder.py | 37 +++++++++++++++++++++++++++++++++++-- responses/registries.py | 31 ------------------------------- 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/responses/_recorder.py b/responses/_recorder.py index 097a5725..21f8317d 100644 --- a/responses/_recorder.py +++ b/responses/_recorder.py @@ -2,16 +2,23 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover + import io + import os + from typing import Any from typing import Callable + from typing import Dict + from typing import List from typing import Type from typing import Union - import os from responses import FirstMatchRegistry from responses import HTTPAdapter from responses import PreparedRequest from responses import models from responses import _F + from responses import BaseResponse + +import toml as _toml from responses import RequestsMock from responses import Response @@ -19,6 +26,32 @@ from responses.registries import OrderedRegistry +def _dump(registered: "List[BaseResponse]", destination: "io.IOBase") -> None: + data: Dict[str, Any] = {"responses": []} + for rsp in registered: + try: + content_length = rsp.auto_calculate_content_length # type: ignore[attr-defined] + data["responses"].append( + { + "response": { + "method": rsp.method, + "url": rsp.url, + "body": rsp.body, # type: ignore[attr-defined] + "status": rsp.status, # type: ignore[attr-defined] + "headers": rsp.headers, + "content_type": rsp.content_type, + "auto_calculate_content_length": content_length, + } + } + ) + except AttributeError as exc: # pragma: no cover + raise AttributeError( + "Cannot dump response object." + "Probably you use custom Response object that is missing required attributes" + ) from exc + _toml.dump(data, destination) + + class Recorder(RequestsMock): def __init__( self, @@ -39,7 +72,7 @@ def wrapper(*args: "Any", **kwargs: "Any") -> "Any": # type: ignore[misc] with self: ret = function(*args, **kwargs) with open(file_path, "w") as file: - self.get_registry()._dump(file) + _dump(self.get_registry().registered, file) return ret diff --git a/responses/registries.py b/responses/registries.py index 1e80a36c..049df23f 100644 --- a/responses/registries.py +++ b/responses/registries.py @@ -1,17 +1,11 @@ import copy from typing import TYPE_CHECKING -from typing import Any -from typing import Dict from typing import List from typing import Optional from typing import Tuple -import toml as _toml - if TYPE_CHECKING: # pragma: no cover # import only for linter run - import io - from requests import PreparedRequest from responses import BaseResponse @@ -79,31 +73,6 @@ def replace(self, response: "BaseResponse") -> "BaseResponse": self.registered[index] = response return response - def _dump(self, destination: "io.IOBase") -> None: - data: Dict[str, Any] = {"responses": []} - for rsp in self.registered: - try: - content_length = rsp.auto_calculate_content_length # type: ignore[attr-defined] - data["responses"].append( - { - "response": { - "method": rsp.method, - "url": rsp.url, - "body": rsp.body, # type: ignore[attr-defined] - "status": rsp.status, # type: ignore[attr-defined] - "headers": rsp.headers, - "content_type": rsp.content_type, - "auto_calculate_content_length": content_length, - } - } - ) - except AttributeError as exc: # pragma: no cover - raise AttributeError( - "Cannot dump response object." - "Probably you use custom Response object that is missing required attributes" - ) from exc - _toml.dump(data, destination) - class OrderedRegistry(FirstMatchRegistry): def find(