Repository: getsentry/responses

Issue:
Add an "update" (upsert) method for adding responses to RequestMock
RequestMock currently has add, remove and replace.

However replace doesn't work if the result does not already exist, meaning you need to write a lot of remove/add code (which does work if the result does not already exist) when updating a RequestMock.

Ideally there would be an "upsert" type method which would wrap up this type of operation, leading to tighter test code.

Due to the ordering of RequestMock, a directionality would probably be needed for common use cases - i.e. add to the end if does not exist, or add to the beginning if does not exist.

Discussion:
codecov[bot]: # [Codecov](https://app.codecov.io/gh/getsentry/responses/compare/363?src=pr&el=h1) Report
> Merging [#363](https://app.codecov.io/gh/getsentry/responses/compare/363?src=pr&el=desc) (c72efb8) into [master](https://codecov.io/gh/getsentry/responses/commit/5aa2844bcf6288da466b834b584e353cfacb5614?el=desc) (5aa2844) will **decrease** coverage by `2.32%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/getsentry/responses/pull/363/graphs/tree.svg?width=650&height=150&src=pr&token=x2tAGR7rxE)](https://app.codecov.io/gh/getsentry/responses/compare/363?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #363      +/-   ##
==========================================
- Coverage   96.25%   93.93%   -2.33%     
==========================================
  Files           3        3              
  Lines        1282     1253      -29     
==========================================
- Hits         1234     1177      -57     
- Misses         48       76      +28     
```


| [Impacted Files](https://app.codecov.io/gh/getsentry/responses/compare/363?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [responses.py](https://app.codecov.io/gh/getsentry/responses/compare/363/diff?src=pr&el=tree#diff-cmVzcG9uc2VzLnB5) | `93.84% <100.00%> (-3.85%)` | :arrow_down: |
| [test\_responses.py](https://app.codecov.io/gh/getsentry/responses/compare/363/diff?src=pr&el=tree#diff-dGVzdF9yZXNwb25zZXMucHk=) | `96.34% <100.00%> (-1.48%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://app.codecov.io/gh/getsentry/responses/compare/363?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/getsentry/responses/compare/363?src=pr&el=footer). Last update [5aa2844...c72efb8](https://app.codecov.io/gh/getsentry/responses/compare/363?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..42cf824
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,71 @@
+name: CI
+
+on:
+  push:
+    branches:
+      - master
+  pull_request:
+    branches:
+      - '*'
+
+jobs:
+  linting:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+
+    steps:
+    - uses: actions/checkout@v1
+      with:
+        fetch-depth: 1
+
+    - uses: actions/setup-python@v2
+      with:
+        python-version: 3.8
+
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install -U requests
+        make install-pre-commit
+        make install-deps
+
+    - name: Run pre-commit
+      run: |
+        pre-commit run -a -v
+
+    - name: Run mypy
+      run: |
+        mypy --config-file=./mypy.ini -p responses
+
+  tests:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9']
+        requests-version: ['"requests>=2.0,<3.0"', '-U requests']
+
+    steps:
+    - uses: actions/checkout@v1
+      with:
+        fetch-depth: 1
+
+    - uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install ${{ matrix.requests-version }}
+        make install-deps
+
+    - name: Run Pytest
+      run: |
+        # Run test
+        pytest . --cov-report term-missing --cov .
+
+    - name: Code Coverage Report
+      if: success()
+      uses: codecov/codecov-action@v1
diff --git a/.travis.yml b/.travis.yml
index 44ca124..eae8538 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -2,13 +2,7 @@ os: linux
 dist: xenial
 language: python
 python:
-- '2.7'
-- '3.5'
-- '3.6'
-- '3.7'
 - '3.8'
-- '3.9'
-- nightly
 
 cache:
   directories:
@@ -16,9 +10,7 @@ cache:
 
 env:
   jobs:
-  - REQUESTS="requests>=2.0,<3.0"
   - REQUESTS="-U requests"
-  - REQUESTS="-e git+git://github.com/requests/requests.git#egg=requests"
   global:
   - PIP_DOWNLOAD_CACHE=".pip_download_cache"
 
@@ -34,7 +26,6 @@ install:
 
 script:
 - make
-- if [[ $TRAVIS_PYTHON_VERSION == '3.6' ]]; then pre-commit run -a -v; fi
 - pytest . --cov-report term-missing  --cov . --cov-report="xml:.artifacts/coverage.xml"
   --junit-xml=".artifacts/junit.xml"
 
@@ -47,26 +38,3 @@ deploy:
     secure: SejRAkh567ae8HNgLMWebpPwOxn2+wfrKWHkm8JTmCJcxKIQmfbPlUJPAspJ9HF7kBB1MzkkqNkXG184VEgH1l1JqUNyZKqrk+y4R3s+jjJ+Yqepm3SeAiq5FceS8BwCegO9L3po299NtdWFHllydVLLL+Rf3ZnCQOl5YT1tzzk=
   on:
     tags: true
-
-after_success:
-- codecov -e REQUESTS -e TRAVIS_PYTHON_VERSION
-- npm install -g @zeus-ci/cli
-- zeus upload -t "text/x-pycodestyle" .artifacts/flake8.log
-- zeus upload -t "text/xml+xunit" .artifacts/*junit.xml
-- zeus upload -t "text/xml+coverage" .artifacts/*coverage.xml
-
-after_failure:
-- npm install -g @zeus-ci/cli
-- zeus upload -t "text/x-pycodestyle" .artifacts/flake8.log
-- zeus upload -t "text/xml+xunit" .artifacts/*junit.xml
-- zeus upload -t "text/xml+coverage" .artifacts/*coverage.xml
-
-notifications:
-  webhooks:
-    urls:
-    - https://zeus.ci/hooks/4f39f396-3820-11e8-982a-0a580a28001b/public/provider/travis/webhook
-    on_success: always
-    on_failure: always
-    on_start: always
-    on_cancel: always
-    on_error: always
diff --git a/README.rst b/README.rst
index 70e911b..6b62102 100644
--- a/README.rst
+++ b/README.rst
@@ -475,6 +475,10 @@ replaced.
         assert resp.json() == {'data': 2}
 
 
+The ``upsert`` function allows a previously registered ``response`` to be
+changed like ``replace``. If the response is registered, the ``upsert`` function
+will registered it like ``add``.
+
 ``remove`` takes a ``method`` and ``url`` argument and will remove **all**
 matched responses from the registered list.
 
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000..8e109ff
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,37 @@
+[mypy]
+show_column_numbers=True
+show_error_codes = True
+
+disallow_any_unimported=True
+disallow_any_expr=False
+disallow_any_decorated=True
+disallow_any_explicit=False
+disallow_any_generics=True
+disallow_subclassing_any=True
+
+disallow_untyped_calls=True
+disallow_untyped_defs=True
+disallow_incomplete_defs=True
+check_untyped_defs=True
+disallow_untyped_decorators=True
+
+no_implicit_optional=True
+strict_optional=True
+
+warn_redundant_casts=True
+warn_unused_ignores=True
+warn_no_return=True
+warn_return_any=False
+warn_unreachable=False
+
+strict_equality=True
+
+ignore_missing_imports=False
+
+[mypy-urllib3.*]
+ignore_missing_imports=True
+
+[mypy-responses.test_responses]
+disallow_untyped_defs=False
+disallow_untyped_calls=False
+disallow_any_decorated=False
diff --git a/responses/__init__.py b/responses/__init__.py
index 24d60e1..7e4a6e5 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -621,6 +621,20 @@ class RequestsMock(object):
             raise ValueError("Response is not registered for URL %s" % url)
         self._matches[index] = response
 
+    def upsert(self, method_or_response=None, url=None, body="", *args, **kwargs):
+        """
+        Replaces a response previously added using ``add()``, or adds the response
+        if no response exists.  Responses are matched using ``method``and ``url``.
+        The first matching response is replaced.
+
+        >>> responses.add(responses.GET, 'http://example.org', json={'data': 1})
+        >>> responses.upsert(responses.GET, 'http://example.org', json={'data': 2})
+        """
+        try:
+            self.replace(method_or_response, url, body, *args, **kwargs)
+        except ValueError:
+            self.add(method_or_response, url, body, *args, **kwargs)
+
     def add_callback(
         self, method, url, callback, match_querystring=False, content_type="text/plain"
     ):
@@ -802,6 +816,7 @@ __all__ = [
     "start",
     "stop",
     "target",
+    "upsert",
 ]
 
 activate = _default_mock.activate
@@ -826,3 +841,4 @@ response_callback = _default_mock.response_callback
 start = _default_mock.start
 stop = _default_mock.stop
 target = _default_mock.target
+upsert = _default_mock.upsert
diff --git a/responses/__init__.pyi b/responses/__init__.pyi
index 9a36e88..5823a60 100644
--- a/responses/__init__.pyi
+++ b/responses/__init__.pyi
@@ -159,7 +159,7 @@ class RequestsMock:
     ) -> None: ...
     def reset(self) -> None: ...
     add: _Add
-    def add_passthru(self, prefix: str) -> None: ...
+    add_passthru: _AddPassthru
     def remove(
         self,
         method_or_response: Optional[Union[str, Response]] = ...,
@@ -184,7 +184,7 @@ class _Activate(Protocol):
 class _Add(Protocol):
     def __call__(
         self,
-        method: Optional[str] = ...,
+        method: Optional[Union[str, BaseResponse]] = ...,
         url: Optional[Union[Pattern[str], str]] = ...,
         body: _Body = ...,
         json: Optional[Any] = ...,
@@ -207,17 +207,22 @@ class _AddCallback(Protocol):
         content_type: Optional[str] = ...,
     ) -> None: ...
 
+class _AddPassthru(Protocol):
+    def __call__(
+        self, prefix: Union[Pattern[str], str]
+    ) -> None: ...
+
 class _Remove(Protocol):
     def __call__(
         self,
-        method_or_response: Optional[Union[str, Response]] = ...,
+        method_or_response: Optional[Union[str, BaseResponse]] = ...,
         url: Optional[Union[Pattern[str], str]] = ...,
     ) -> None: ...
 
 class _Replace(Protocol):
     def __call__(
         self,
-        method_or_response: Optional[Union[str, Response]] = ...,
+        method_or_response: Optional[Union[str, BaseResponse]] = ...,
         url: Optional[Union[Pattern[str], str]] = ...,
         body: _Body = ...,
         json: Optional[Any] = ...,
@@ -233,7 +238,7 @@ class _Replace(Protocol):
 activate: _Activate
 add: _Add
 add_callback: _AddCallback
-add_passthru: Callable[[str], None]
+add_passthru: _AddPassthru
 assert_all_requests_are_fired: bool
 assert_call_count: Callable[[str, int], bool]
 calls: CallList
diff --git a/setup.py b/setup.py
index 399f43e..2661439 100644
--- a/setup.py
+++ b/setup.py
@@ -34,6 +34,7 @@ tests_require = [
     "pytest-cov",
     "pytest-localserver",
     "flake8",
+    "mypy; python_version >= '3.5'",
 ]
 
 extras_require = {"tests": tests_require}


Test patch:
diff --git a/responses/test_responses.py b/responses/test_responses.py
index 0584a3c..fdab8da 100644
--- a/responses/test_responses.py
+++ b/responses/test_responses.py
@@ -16,7 +16,7 @@ from responses import BaseResponse, Response
 try:
     from mock import patch, Mock
 except ImportError:
-    from unittest.mock import patch, Mock
+    from unittest.mock import patch, Mock  # type: ignore
 
 
 def assert_reset():
@@ -34,6 +34,14 @@ def assert_response(resp, body=None, content_type="text/plain"):
     assert resp.text == body
 
 
+def assert_params(resp, expected):
+    assert hasattr(resp, "request"), "Missing request"
+    assert hasattr(
+        resp.request, "params"
+    ), "Missing params on request that responses should add"
+    assert getattr(resp.request, "params") == expected, "Incorrect parameters"
+
+
 def test_response():
     @responses.activate
     def run():
@@ -164,6 +172,77 @@ def test_replace_response_object_error():
     assert_reset()
 
 
+@pytest.mark.parametrize(
+    "original,replacement",
+    [
+        ("http://example.com/two", "http://example.com/two"),
+        (
+            Response(method=responses.GET, url="http://example.com/two"),
+            Response(
+                method=responses.GET, url="http://example.com/two", body="testtwo"
+            ),
+        ),
+        (
+            re.compile(r"http://example\.com/two"),
+            re.compile(r"http://example\.com/two"),
+        ),
+    ],
+)
+def test_upsert_replace(original, replacement):
+    @responses.activate
+    def run():
+        responses.add(responses.GET, "http://example.com/one", body="test1")
+
+        if isinstance(original, BaseResponse):
+            responses.add(original)
+        else:
+            responses.add(responses.GET, original, body="test2")
+
+        if isinstance(replacement, BaseResponse):
+            responses.upsert(replacement)
+        else:
+            responses.upsert(responses.GET, replacement, body="testtwo")
+
+        resp = requests.get("http://example.com/two")
+        assert_response(resp, "testtwo")
+
+    run()
+    assert_reset()
+
+
+@pytest.mark.parametrize(
+    "original,replacement",
+    [
+        ("http://example.com/two", "http://example.com/two"),
+        (
+            Response(method=responses.GET, url="http://example.com/two"),
+            Response(
+                method=responses.GET, url="http://example.com/two", body="testtwo"
+            ),
+        ),
+        (
+            re.compile(r"http://example\.com/two"),
+            re.compile(r"http://example\.com/two"),
+        ),
+    ],
+)
+def test_upsert_add(original, replacement):
+    @responses.activate
+    def run():
+        responses.add(responses.GET, "http://example.com/one", body="test1")
+
+        if isinstance(replacement, BaseResponse):
+            responses.upsert(replacement)
+        else:
+            responses.upsert(responses.GET, replacement, body="testtwo")
+
+        resp = requests.get("http://example.com/two")
+        assert_response(resp, "testtwo")
+
+    run()
+    assert_reset()
+
+
 def test_remove():
     @responses.activate
     def run():
@@ -802,7 +881,7 @@ def test_response_callback():
             resp = requests.get("http://example.com")
             assert resp.text == "test"
             assert hasattr(resp, "_is_mocked")
-            assert resp._is_mocked is True
+            assert getattr(resp, "_is_mocked") is True
 
     run()
     assert_reset()
@@ -813,8 +892,8 @@ def test_response_filebody():
 
     def run():
         with responses.RequestsMock() as m:
-            with open("README.rst", "rb") as out:
-                m.add(responses.GET, "http://example.com", body=out, stream=True)
+            with open("README.rst", "r") as out:
+                m.add(responses.GET, "http://example.com", body=out.read(), stream=True)
                 resp = requests.get("http://example.com")
             with open("README.rst", "r") as out:
                 assert resp.text == out.read()
@@ -963,7 +1042,7 @@ def test_handles_buffered_reader_body():
 
     @responses.activate
     def run():
-        responses.add(responses.GET, url, body=BufferedReader(BytesIO(b"test")))
+        responses.add(responses.GET, url, body=BufferedReader(BytesIO(b"test")))  # type: ignore
 
         resp = requests.get(url)
 
@@ -1161,11 +1240,11 @@ def test_request_param(url):
         )
         resp = requests.get(url, params=params)
         assert_response(resp, "test")
-        assert resp.request.params == params
+        assert_params(resp, params)
 
         resp = requests.get(url)
         assert_response(resp, "test")
-        assert resp.request.params == {}
+        assert_params(resp, {})
 
     run()
     assert_reset()
@@ -1183,7 +1262,7 @@ def test_request_param_with_multiple_values_for_the_same_key():
         )
         resp = requests.get(url, params=params)
         assert_response(resp, "test")
-        assert resp.request.params == params
+        assert_params(resp, params)
 
     run()
     assert_reset()