├── .craft.yml ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.yml ├── PULL_REQUEST_TEMPLATE.md ├── codeql │ └── codeql-config.yml └── workflows │ ├── build.yml │ ├── ci.yml │ ├── codeql.yml │ ├── enforce-license-compliance.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json └── settings.json ├── CHANGES ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── mypy.ini ├── pyproject.toml ├── responses ├── __init__.py ├── _recorder.py ├── matchers.py ├── py.typed ├── registries.py └── tests │ ├── __init__.py │ ├── test_matchers.py │ ├── test_multithreading.py │ ├── test_recorder.py │ ├── test_registries.py │ └── test_responses.py ├── scripts └── bump-version.sh ├── setup.py └── tox.ini /.craft.yml: -------------------------------------------------------------------------------- 1 | minVersion: "0.21.0" 2 | github: 3 | owner: getsentry 4 | repo: responses 5 | changelog: CHANGES 6 | targets: 7 | - name: pypi 8 | - name: github 9 | - name: sentry-pypi 10 | internalPypiRepo: getsentry/pypi 11 | requireNames: 12 | - /^responses-.+-py3-none-any.whl$/ 13 | - /^responses-.+.tar.gz$/ 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: Bug report for getsentry/responses project 3 | body: 4 | - type: textarea 5 | id: descriptionshort 6 | attributes: 7 | label: Describe the bug 8 | description: A clear and concise description of what the bug is. 9 | validations: 10 | required: true 11 | - type: textarea 12 | id: descriptionlong 13 | attributes: 14 | label: Additional context 15 | description: Add any other context about the problem here. 16 | validations: 17 | required: false 18 | - type: input 19 | id: version 20 | attributes: 21 | label: Version of `responses` 22 | placeholder: 0.20.0 ← should look like this 23 | description: Version of `responses` package. Please first validate in the latest available version. 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: repro 28 | attributes: 29 | label: Steps to Reproduce 30 | description: |- 31 | Provide a minimal reproducible self-contained code snippet. 32 | Snippet must be as small as possible and ready to run. 33 | value: |- 34 | ```python 35 | # your code goes here 36 | 37 | ``` 38 | validations: 39 | required: true 40 | - type: textarea 41 | id: expected 42 | attributes: 43 | label: Expected Result 44 | description: A clear and concise description of what you expected to happen. 45 | validations: 46 | required: true 47 | - type: textarea 48 | id: actual 49 | attributes: 50 | label: Actual Result 51 | description: A clear and concise description of what actually happens. 52 | validations: 53 | required: true 54 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What type of PR is this? (check all applicable) 2 | 3 | - [ ] Refactor 4 | - [ ] Feature 5 | - [ ] Bug Fix 6 | - [ ] Optimization 7 | - [ ] Documentation Update 8 | - [ ] Other 9 | 10 | ## Description 11 | 12 | 13 | ## Related Issues 14 | 15 | - Closes # 16 | 17 | 18 | ### PR checklist 19 | Before submitting this pull request, I have done the following: 20 | - [ ] Read the [contributing guidelines](https://github.com/getsentry/responses?tab=readme-ov-file#contributing) 21 | - [ ] Ran `tox` and `pre-commit` checks locally 22 | - [ ] Added my changes to the [CHANGES](./../CHANGES) file 23 | 24 | 25 | ## Added/updated tests? 26 | > Current repository has 100% test coverage. 27 | 28 | - [ ] Yes 29 | - [ ] No, and this is why: 31 | - [ ] I need help with writing tests 32 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: "Sentry CodeQL Config" 2 | 3 | paths-ignore: 4 | - '**/tests/**' 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - release/** 7 | 8 | jobs: 9 | dist: 10 | name: distribution packages 11 | timeout-minutes: 10 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-python@v2 17 | with: 18 | python-version: 3.9 19 | 20 | - run: | 21 | pip install wheel 22 | python setup.py bdist_wheel sdist --formats=gztar 23 | - uses: actions/upload-artifact@v4 24 | with: 25 | name: ${{ github.sha }} 26 | path: dist/* 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | linting: 13 | runs-on: ubuntu-22.04 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install tox 20 | 21 | - name: Run pre-commit 22 | run: | 23 | tox -e precom 24 | 25 | - name: Run mypy 26 | run: | 27 | tox -e mypy 28 | 29 | tests: 30 | runs-on: ubuntu-latest 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 35 | requests-version: ['"requests>=2.0,<3.0"'] 36 | urllib3-version: ['"urllib3<2"', '"urllib3>=2,<3.0"'] 37 | 38 | 39 | steps: 40 | - uses: actions/checkout@v3 41 | with: 42 | fetch-depth: 1 43 | 44 | - uses: actions/setup-python@v4 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | 48 | - name: Install dependencies 49 | run: | 50 | python -m pip install --upgrade pip 51 | make install-deps 52 | pip install ${{ matrix.requests-version }} ${{ matrix.urllib3-version }} 53 | 54 | - name: Run Pytest 55 | run: | 56 | # Run test 57 | pytest . --asyncio-mode=auto --cov-report term-missing --cov-report xml --cov responses 58 | 59 | - name: Code Coverage Report 60 | if: success() 61 | uses: codecov/codecov-action@v3 62 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: '36 14 * * 1' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ 'python' ] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v3 28 | 29 | # Initializes the CodeQL tools for scanning. 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v2 32 | with: 33 | languages: ${{ matrix.language }} 34 | 35 | 36 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 37 | # If this step fails, then you should remove it and run the build manually (see below) 38 | - name: Autobuild 39 | uses: github/codeql-action/autobuild@v2 40 | 41 | # ℹ️ Command-line programs to run using the OS shell. 42 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 43 | 44 | # If the Autobuild fails above, remove it and uncomment the following three lines. 45 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 46 | 47 | # - run: | 48 | # echo "Run, Build Application using script" 49 | # ./location_of_script_within_repo/buildscript.sh 50 | 51 | - name: Perform CodeQL Analysis 52 | uses: github/codeql-action/analyze@v2 53 | with: 54 | category: "/language:${{matrix.language}}" 55 | -------------------------------------------------------------------------------- /.github/workflows/enforce-license-compliance.yml: -------------------------------------------------------------------------------- 1 | name: Enforce License Compliance 2 | 3 | on: 4 | push: 5 | branches: [master, release/*] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | enforce-license-compliance: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: 'Enforce License Compliance' 14 | uses: getsentry/action-enforce-license-compliance@main 15 | with: 16 | fossa_api_key: ${{ secrets.FOSSA_API_KEY }} 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: Version to release 8 | required: true 9 | force: 10 | description: Force a release even when there are release-blockers (optional) 11 | required: false 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | name: 'Release a new version' 17 | steps: 18 | - name: Get auth token 19 | id: token 20 | uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 21 | with: 22 | app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} 23 | private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} 24 | 25 | - uses: actions/checkout@v2 26 | with: 27 | token: ${{ steps.token.outputs.token }} 28 | fetch-depth: 0 29 | 30 | - name: Prepare release 31 | uses: getsentry/action-prepare-release@v1 32 | env: 33 | GITHUB_TOKEN: ${{ steps.token.outputs.token }} 34 | with: 35 | version: ${{ github.event.inputs.version }} 36 | force: ${{ github.event.inputs.force }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .arcconfig 2 | .coverage 3 | .DS_Store 4 | .idea 5 | .env 6 | venv 7 | *.db 8 | *.egg-info 9 | *.pyc 10 | /htmlcov 11 | /dist 12 | /build 13 | /.cache 14 | /.pytest_cache 15 | /.tox 16 | /.artifacts 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.3.0 4 | hooks: 5 | - id: black 6 | args: [--line-length=88, --safe] 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.4.0 9 | hooks: 10 | - id: check-case-conflict 11 | - id: check-merge-conflict 12 | - id: check-symlinks 13 | - id: end-of-file-fixer 14 | - id: trailing-whitespace 15 | - id: debug-statements 16 | - id: requirements-txt-fixer 17 | - repo: https://github.com/pycqa/isort 18 | rev: 5.12.0 19 | hooks: 20 | - id: isort 21 | name: isort (python) 22 | args: ['--force-single-line-imports', '--profile', 'black'] 23 | - repo: https://github.com/pycqa/flake8 24 | rev: 6.0.0 25 | hooks: 26 | - id: flake8 27 | args: [ '--max-line-length', '100', '--max-doc-length', '120' ] 28 | - repo: https://github.com/asottile/pyupgrade 29 | rev: v3.10.1 30 | hooks: 31 | - id: pyupgrade 32 | args: [--py37-plus] 33 | - repo: https://github.com/adamchainz/blacken-docs 34 | rev: 1.14.0 35 | hooks: 36 | - id: blacken-docs 37 | additional_dependencies: [ black == 23.3.0 ] 38 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "donjayamanne.python", 6 | "lextudio.restructuredtext" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/*.pyc": true, 4 | "**/*.min.js": true, 5 | "**/*.js.map": true, 6 | "node_modules": true, 7 | "htmlcov": true, 8 | "build": true, 9 | "static": true, 10 | "*.log": true, 11 | "*.egg-info": true, 12 | ".vscode/tags": true, 13 | ".mypy_cache": true 14 | }, 15 | "files.trimTrailingWhitespace": false, 16 | "files.trimFinalNewlines": false, 17 | "files.insertFinalNewline": true, 18 | 19 | "[python]": { 20 | "editor.detectIndentation": false, 21 | "editor.tabSize": 4, 22 | "editor.formatOnSave": true 23 | }, 24 | 25 | "python.linting.pylintEnabled": false, 26 | "python.linting.flake8Enabled": true, 27 | "python.formatting.provider": "black", 28 | "python.pythonPath": "${env.WORKON_HOME}/responses/bin/python", 29 | "python.testing.pytestEnabled": false, 30 | "python.testing.unittestEnabled": false, 31 | "python.testing.nosetestsEnabled": false 32 | } 33 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 0.25.7 2 | ------ 3 | 4 | * Added support for python 3.13 5 | 6 | 0.25.6 7 | ------ 8 | 9 | * Added py.typed to package_data 10 | 11 | 0.25.5 12 | ------ 13 | 14 | * Fix readme issue that prevented 0.25.4 from being published to pypi. 15 | 16 | 0.25.4 17 | ------ 18 | 19 | * Responses can now match requests that use `data` with file-like objects. 20 | Files will be read as bytes and stored in the request mock. See #736 21 | * `RequestsMock.matchers` was added. This property is an alias to `responses.matchers`. See #739 22 | * Removed tests from packaged wheels. See #746 23 | * Improved recorder API to ease use in REPL environments. See #745 24 | 25 | 0.25.3 26 | ------ 27 | 28 | * Fixed `recorder` not saving and loading response headers with yaml files. See #715 29 | 30 | 0.25.2 31 | ------ 32 | 33 | * Mulligan on 0.25.1 to run release pipeline correctly. 34 | * Added `matchers.body_matcher` for matching string request bodies. See #717 35 | 36 | 0.25.1 37 | ------ 38 | 39 | * Fixed tests failures during RPM package builds. See #706 40 | * Fix mocked HEAD responses that have `Content-Length` set. See #712 41 | * Fixed error messages when matches fail: inputs are not sorted or reformatted. See #704 42 | 43 | 0.25.0 44 | ------ 45 | 46 | * Added support for Python 3.12 47 | * Fixed `matchers.header_matcher` not failing when a matched header is missing from the request. See #702 48 | 49 | 50 | 0.24.1 51 | ------ 52 | 53 | * Reverted overloads removal 54 | * Added typing to `Call` attributes. 55 | * Fix socket issues (see #693) 56 | 57 | 58 | 0.24.0 59 | ------ 60 | 61 | * Added `BaseResponse.calls` to access calls data of a separate mocked request. See #664 62 | * Added `real_adapter_send` parameter to `RequestsMock` that will allow users to set 63 | through which function they would like to send real requests 64 | * Added support for re.Pattern based header matching. 65 | * Added support for gzipped response bodies to `json_params_matcher`. 66 | * Fix `Content-Type` headers issue when the header was duplicated. See #644 67 | * Moved types-pyyaml dependency to `tests_requires` 68 | * Removed Python3.7 support 69 | 70 | 0.23.3 71 | ------ 72 | 73 | * Allow urllib3>=1.25.10 74 | 75 | 76 | 0.23.2 77 | ------ 78 | 79 | > This release is the last to support Python 3.7 80 | 81 | * Updated dependency to urllib3>=2 and requests>=2.30.0. See #635 82 | * Fixed issue when custom adapters were sending only positional args. See #642 83 | * Expose `unbound_on_send` method in `RequestsMock` class. This method returns new function 84 | that is called by `RequestsMock` instead of original `send` method defined by any adapter. 85 | 86 | 87 | 0.23.1 88 | ------ 89 | 90 | * Remove `tomli` import. See #630 91 | 92 | 0.23.0 93 | ------ 94 | 95 | * Add Python 3.11 support 96 | * Fix type annotations of `CallList`. See #593 97 | * `request` object is attached to any custom exception provided as `Response` `body` argument. See #588 98 | * Fixed mocked responses leaking between tests when `assert_all_requests_are_fired` and a request was not fired. 99 | * [BETA] Default recorder format was changed to YAML. Added `responses.RequestsMock._parse_response_file` and 100 | `responses._recorder.Recorder.dump_to_file` methods that allow users to override default parser to eg toml, json 101 | 102 | 0.22.0 103 | ------ 104 | 105 | * Update `requests` dependency to the version of 2.22.0 or higher. See #584. 106 | * [BETA] Added possibility to record responses to TOML files via `@_recorder.record(file_path="out.toml")` decorator. 107 | * [BETA] Added possibility to replay responses (populate registry) from TOML files 108 | via `responses._add_from_file(file_path="out.toml")` method. 109 | * Fix type for the `mock`'s patcher object. See #556 110 | * Fix type annotation for `CallList` 111 | * Add `passthrough` argument to `BaseResponse` object. See #557 112 | * Fix `registries` leak. See #563 113 | * `OriginalResponseShim` is removed. See #585 114 | * Add support for the `loose` version of `json_params_matcher` via named argument `strict_match`. See #551 115 | * Add lists support as JSON objects in `json_params_matcher`. See #559 116 | * Added project links to pypi listing. 117 | * `delete`, `get`, `head`, `options`, `patch`, `post`, `put` shortcuts are now implemented using `functools.partialmethod`. 118 | * Fix `MaxRetryError` exception. Replace exception by `RetryError` according to `requests` implementation. See #572. 119 | * Adjust error message when `Retry` is exhausted. See #580. 120 | 121 | 0.21.0 122 | ------ 123 | 124 | * Add `threading.Lock()` to allow `responses` working with `threading` module. 125 | * Add `urllib3` `Retry` mechanism. See #135 126 | * Removed internal `_cookies_from_headers` function 127 | * Now `add`, `upsert`, `replace` methods return registered response. 128 | `remove` method returns list of removed responses. 129 | * Added null value support in `urlencoded_params_matcher` via `allow_blank` keyword argument 130 | * Added strict version of decorator. Now you can apply `@responses.activate(assert_all_requests_are_fired=True)` 131 | to your function to validate that all requests were executed in the wrapped function. See #183 132 | 133 | 134 | 0.20.0 135 | ------ 136 | 137 | * Deprecate `responses.assert_all_requests_are_fired`, `responses.passthru_prefixes`, `responses.target` 138 | since they are not actual properties of the class instance. 139 | Use `responses.mock.assert_all_requests_are_fired`, 140 | `responses.mock.passthru_prefixes`, `responses.mock.target` instead. 141 | * Fixed the issue when `reset()` method was called in not stopped mock. See #511 142 | 143 | 0.19.0 144 | ------ 145 | 146 | * Added a registry that provides more strict ordering based on the invocation index. 147 | See `responses.registries.OrderedRegistry`. 148 | * Added shortcuts for each request method: delete, get, head, options, patch, post, put. 149 | For example, to add response for POST request you can use `responses.post()` instead 150 | of `responses.add(responses.POST)`. 151 | * Prevent `responses.activate` decorator to leak, if wrapped function called from within another 152 | wrapped function. Also, allow calling of above mentioned chain. See #481 for more details. 153 | * Expose `get_registry()` method of `RequestsMock` object. Replaces internal `_get_registry()`. 154 | * `query_param_matcher` can now accept dictionaries with `int` and `float` values. 155 | * Add support for the `loose` version of `query_param_matcher` via named argument `strict_match`. 156 | * Added support for `async/await` functions. 157 | * `response_callback` is no longer executed on exceptions raised by failed `Response`s 158 | * Change logic of `_get_url_and_path` to comply with RFC 3986. Now URL match occurs by matching 159 | schema, authority and path, where path is terminated by the first question mark ("?") or 160 | number sign ("#") character, or by the end of the URI. 161 | * An error is now raised when both `content_type` and `headers[content-type]` are provided as parameters. 162 | * When a request isn't matched the passthru prefixes are now included in error messages. 163 | 164 | 165 | 0.18.0 166 | ------ 167 | 168 | * Dropped support of Python 2.7, 3.5, 3.6 169 | * Fixed issue with type annotation for `responses.activate` decorator. See #468 170 | * Removed internal `_is_string` and `_ensure_str` functions 171 | * Removed internal `_quote` from `test_responses.py` 172 | * Removed internal `_matches` attribute of `RequestsMock` object. 173 | * Generated decorator wrapper now uses stdlib features instead of strings and exec 174 | * Fix issue when Deprecation Warning was raised with default arguments 175 | in `responses.add_callback` due to `match_querystring`. See #464 176 | 177 | 0.17.0 178 | ------ 179 | 180 | * This release is the last to support Python 2.7. 181 | * Fixed issue when `response.iter_content` when `chunk_size=None` entered infinite loop 182 | * Fixed issue when `passthru_prefixes` persisted across tests. 183 | Now `add_passthru` is valid only within a context manager or for a single function and 184 | cleared on exit 185 | * Deprecate `match_querystring` argument in `Response` and `CallbackResponse`. 186 | Use `responses.matchers.query_param_matcher` or `responses.matchers.query_string_matcher` 187 | * Added support for non-UTF-8 bytes in `responses.matchers.multipart_matcher` 188 | * Added `responses.registries`. Now user can create custom registries to 189 | manipulate the order of responses in the match algorithm 190 | `responses.activate(registry=CustomRegistry)` 191 | * Fixed issue with response match when requests were performed between adding responses with 192 | same URL. See Issue #212 193 | 194 | 0.16.0 195 | ------ 196 | 197 | * Fixed regression with `stream` parameter deprecation, requests.session() and cookie handling. 198 | * Replaced adhoc URL parsing with `urllib.parse`. 199 | * Added ``match`` parameter to ``add_callback`` method 200 | * Added `responses.matchers.fragment_identifier_matcher`. This matcher allows you 201 | to match request URL fragment identifier. 202 | * Improved test coverage. 203 | * Fixed failing test in python 2.7 when `python-future` is also installed. 204 | 205 | 0.15.0 206 | ------ 207 | 208 | * Added `responses.PassthroughResponse` and 209 | `reponses.BaseResponse.passthrough`. These features make building passthrough 210 | responses more compatible with dynamcially generated response objects. 211 | * Removed the unused ``_is_redirect()`` function from responses internals. 212 | * Added `responses.matchers.request_kwargs_matcher`. This matcher allows you 213 | to match additional request arguments like `stream`. 214 | * Added `responses.matchers.multipart_matcher`. This matcher allows you 215 | to match request body and headers for ``multipart/form-data`` data 216 | * Added `responses.matchers.query_string_matcher`. This matcher allows you 217 | to match request query string, similar to `responses.matchers.query_param_matcher`. 218 | * Added `responses.matchers.header_matcher()`. This matcher allows you to match 219 | request headers. By default only headers supplied to `header_matcher()` are checked. 220 | You can make header matching exhaustive by passing `strict_match=True` to `header_matcher()`. 221 | * Changed all matchers output message in case of mismatch. Now message is aligned 222 | between Python2 and Python3 versions 223 | * Deprecate ``stream`` argument in ``Response`` and ``CallbackResponse`` 224 | * Added Python 3.10 support 225 | 226 | 0.14.0 227 | ------ 228 | 229 | * Added `responses.matchers`. 230 | * Moved `responses.json_params_matcher` to `responses.matchers.json_params_matcher` 231 | * Moved `responses.urlencoded_params_matcher` to 232 | `responses.matchers.urlencoded_params_matcher` 233 | * Added `responses.matchers.query_param_matcher`. This matcher allows you 234 | to match query strings with a dictionary. 235 | * Added `auto_calculate_content_length` option to `responses.add()`. When 236 | enabled, this option will generate a `Content-Length` header 237 | based on the number of bytes in the response body. 238 | 239 | 0.13.4 240 | ------ 241 | 242 | * Improve typing support 243 | * Use URLs with normalized hostnames when comparing URLs. 244 | 245 | 0.13.3 246 | ------ 247 | 248 | * Switch from Travis to GHA for deployment. 249 | 250 | 0.13.2 251 | ------ 252 | 253 | * Fixed incorrect type stubs for `add_callback` 254 | 255 | 0.13.1 256 | ------ 257 | 258 | * Fixed packages not containing type stubs. 259 | 260 | 0.13.0 261 | ------ 262 | 263 | * `responses.upsert()` was added. This method will `add()` a response if one 264 | has not already been registered for a URL, or `replace()` an existing 265 | response. 266 | * `responses.registered()` was added. The method allows you to get a list of 267 | the currently registered responses. This formalizes the previously private 268 | `responses.mock._matches` method. 269 | * A more useful `__repr__` has been added to `Response`. 270 | * Error messages have been improved. 271 | 272 | 0.12.1 273 | ------ 274 | 275 | * `responses.urlencoded_params_matcher` and `responses.json_params_matcher` now 276 | accept None to match empty requests. 277 | * Fixed imports to work with new `urllib3` versions. 278 | * `request.params` now allows parameters to have multiple values for the same key. 279 | * Improved ConnectionError messages. 280 | 281 | 0.12.0 282 | ------ 283 | 284 | - Remove support for Python 3.4. 285 | 286 | 0.11.0 287 | ------ 288 | 289 | - Added the `match` parameter to `add()`. 290 | - Added `responses.urlencoded_params_matcher()` and `responses.json_params_matcher()`. 291 | 292 | 0.10.16 293 | ------- 294 | 295 | - Add a requirements pin to urllib3. This helps prevent broken install states where 296 | cookie usage fails. 297 | 298 | 0.10.15 299 | ------- 300 | 301 | - Added `assert_call_count` to improve ergonomics around ensuring a mock was called. 302 | - Fix incorrect handling of paths with query strings. 303 | - Add Python 3.9 support to CI matrix. 304 | 305 | 0.10.14 306 | ------- 307 | 308 | - Retag of 0.10.13 309 | 310 | 0.10.13 311 | ------- 312 | 313 | - Improved README examples. 314 | - Improved handling of unicode bodies. The inferred content-type for unicode 315 | bodies is now `text/plain; charset=utf-8`. 316 | - Streamlined querysting matching code. 317 | 318 | 0.10.12 319 | ------- 320 | 321 | - Fixed incorrect content-type in `add_callback()` when headers are provided as a list of tuples. 322 | 323 | 0.10.11 324 | ------- 325 | 326 | - Fixed invalid README formatted. 327 | - Fixed string formatting in error message. 328 | 329 | 0.10.10 330 | ------ 331 | 332 | - Added Python 3.8 support 333 | - Remove Python 3.4 from test suite matrix. 334 | - The `response.request` object now has a `params` attribute that contains the query string parameters from the request that was captured. 335 | - `add_passthru` now supports `re` pattern objects to match URLs. 336 | - ConnectionErrors raised by responses now include more details on the request that was attempted and the mocks registered. 337 | 338 | 0.10.9 339 | ------ 340 | 341 | - Fixed regression with `add_callback()` and content-type header. 342 | - Fixed implicit dependency on urllib3>1.23.0 343 | 344 | 0.10.8 345 | ------ 346 | 347 | - Fixed cookie parsing and enabled multiple cookies to be set by using a list of 348 | tuple values. 349 | 350 | 0.10.7 351 | ------ 352 | 353 | - Added pypi badges to README. 354 | - Fixed formatting issues in README. 355 | - Quoted cookie values are returned correctly now. 356 | - Improved compatibility for pytest 5 357 | - Module level method names are no longer generated dynamically improving IDE navigation. 358 | 359 | 0.10.6 360 | ------ 361 | 362 | - Improved documentation. 363 | - Improved installation requirements for py3 364 | - ConnectionError's raised by responses now indicate which request 365 | path/method failed to match a mock. 366 | - `test_responses.py` is no longer part of the installation targets. 367 | 368 | 0.10.5 369 | ------ 370 | 371 | - Improved support for raising exceptions from callback mocks. If a mock 372 | callback returns an exception object that exception will be raised. 373 | 374 | 0.10.4 375 | ------ 376 | 377 | - Fixed generated wrapper when using `@responses.activate` in Python 3.6+ 378 | when decorated functions use parameter and/or return annotations. 379 | 380 | 0.10.3 381 | ------ 382 | 383 | - Fixed deprecation warnings in python 3.7 for inspect module usage. 384 | 385 | 0.10.2 386 | ------ 387 | 388 | - Fixed build setup to use undeprecated `pytest` bin stub. 389 | - Updated `tox` configuration. 390 | - Added example of using responses with `pytest.fixture` 391 | - Removed dependency on `biscuits` in py3. Instead `http.cookies` is being used. 392 | 393 | 0.10.1 394 | ------ 395 | 396 | - Packaging fix to distribute wheel (#219) 397 | 398 | 0.10.0 399 | ------ 400 | 401 | - Fix passing through extra settings (#207) 402 | - Fix collections.abc warning on Python 3.7 (#215) 403 | - Use 'biscuits' library instead of 'cookies' on Python 3.4+ (#218) 404 | 405 | 0.9.0 406 | ----- 407 | 408 | - Support for Python 3.7 (#196) 409 | - Support streaming responses for BaseResponse (#192) 410 | - Support custom patch targets for mock (#189) 411 | - Fix unicode support for passthru urls (#178) 412 | - Fix support for unicode in domain names and tlds (177) 413 | 414 | 0.8.0 415 | ----- 416 | 417 | - Added the ability to passthru real requests via ``add_passthru()`` 418 | and ``passthru_prefixes`` configurations. 419 | 420 | 0.7.0 421 | ----- 422 | 423 | - Responses will now be rotated until the final match is hit, and 424 | then persist using that response (GH-171). 425 | 426 | 0.6.2 427 | ----- 428 | 429 | - Fixed call counting with exceptions (GH-163). 430 | - Fixed behavior with arbitrary status codes (GH-164). 431 | - Fixed handling of multiple responses with the same match (GH-165). 432 | - Fixed default path behavior with ``match_querystring`` (GH-166). 433 | 434 | 0.6.1 435 | ----- 436 | 437 | - Restored ``adding_headers`` compatibility (GH-160). 438 | 439 | 0.6.0 440 | ----- 441 | 442 | - Allow empty list/dict as json object (GH-100). 443 | - Added `response_callback` (GH-151). 444 | - Added ``Response`` interfaces (GH-155). 445 | - Fixed unicode characters in querystring (GH-153). 446 | - Added support for streaming IO buffers (GH-154). 447 | - Added support for empty (unset) Content-Type (GH-139). 448 | - Added reason to mocked responses (GH-132). 449 | - ``yapf`` autoformatting now enforced on codebase. 450 | 451 | 0.5.1 452 | ----- 453 | 454 | - Add LICENSE, README and CHANGES to the PyPI distribution (GH-97). 455 | 456 | 0.5.0 457 | ----- 458 | 459 | - Allow passing a JSON body to `response.add` (GH-82) 460 | - Improve ConnectionError emulation (GH-73) 461 | - Correct assertion in assert_all_requests_are_fired (GH-71) 462 | 463 | 0.4.0 464 | ----- 465 | 466 | - Requests 2.0+ is required 467 | - Mocking now happens on the adapter instead of the session 468 | 469 | 0.3.0 470 | ----- 471 | 472 | - Add the ability to mock errors (GH-22) 473 | - Add responses.mock context manager (GH-36) 474 | - Support custom adapters (GH-33) 475 | - Add support for regexp error matching (GH-25) 476 | - Add support for dynamic bodies via `responses.add_callback` (GH-24) 477 | - Preserve argspec when using `responses.activate` decorator (GH-18) 478 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2015 David Cramer 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst CHANGES LICENSE 2 | recursive-include responses *.py 3 | include tox.ini 4 | recursive-include responses py.typed 5 | global-exclude *~ 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | develop: setup-git install-deps 2 | 3 | install-deps: 4 | pip install -e "file://`pwd`#egg=responses[tests]" 5 | 6 | install-pre-commit: 7 | pip install "pre-commit>=2.9.2" 8 | 9 | setup-git: install-pre-commit 10 | pre-commit install 11 | git config branch.autosetuprebase always 12 | 13 | test: develop lint 14 | @echo "Running Python tests" 15 | py.test . 16 | @echo "" 17 | 18 | lint: install-pre-commit 19 | @echo "Linting Python files" 20 | pre-commit run -a 21 | @echo "" 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Responses 2 | ========= 3 | 4 | .. image:: https://img.shields.io/pypi/v/responses.svg 5 | :target: https://pypi.python.org/pypi/responses/ 6 | 7 | .. image:: https://img.shields.io/pypi/pyversions/responses.svg 8 | :target: https://pypi.org/project/responses/ 9 | 10 | .. image:: https://img.shields.io/pypi/dm/responses 11 | :target: https://pypi.python.org/pypi/responses/ 12 | 13 | .. image:: https://codecov.io/gh/getsentry/responses/branch/master/graph/badge.svg 14 | :target: https://codecov.io/gh/getsentry/responses/ 15 | 16 | A utility library for mocking out the ``requests`` Python library. 17 | 18 | .. note:: 19 | 20 | Responses requires Python 3.8 or newer, and requests >= 2.30.0 21 | 22 | 23 | Table of Contents 24 | ----------------- 25 | 26 | .. contents:: 27 | 28 | 29 | Installing 30 | ---------- 31 | 32 | ``pip install responses`` 33 | 34 | 35 | Deprecations and Migration Path 36 | ------------------------------- 37 | 38 | Here you will find a list of deprecated functionality and a migration path for each. 39 | Please ensure to update your code according to the guidance. 40 | 41 | .. list-table:: Deprecation and Migration 42 | :widths: 50 25 50 43 | :header-rows: 1 44 | 45 | * - Deprecated Functionality 46 | - Deprecated in Version 47 | - Migration Path 48 | * - ``responses.json_params_matcher`` 49 | - 0.14.0 50 | - ``responses.matchers.json_params_matcher`` 51 | * - ``responses.urlencoded_params_matcher`` 52 | - 0.14.0 53 | - ``responses.matchers.urlencoded_params_matcher`` 54 | * - ``stream`` argument in ``Response`` and ``CallbackResponse`` 55 | - 0.15.0 56 | - Use ``stream`` argument in request directly. 57 | * - ``match_querystring`` argument in ``Response`` and ``CallbackResponse``. 58 | - 0.17.0 59 | - Use ``responses.matchers.query_param_matcher`` or ``responses.matchers.query_string_matcher`` 60 | * - ``responses.assert_all_requests_are_fired``, ``responses.passthru_prefixes``, ``responses.target`` 61 | - 0.20.0 62 | - Use ``responses.mock.assert_all_requests_are_fired``, 63 | ``responses.mock.passthru_prefixes``, ``responses.mock.target`` instead. 64 | 65 | Basics 66 | ------ 67 | 68 | The core of ``responses`` comes from registering mock responses and covering test function 69 | with ``responses.activate`` decorator. ``responses`` provides similar interface as ``requests``. 70 | 71 | Main Interface 72 | ^^^^^^^^^^^^^^ 73 | 74 | * responses.add(``Response`` or ``Response args``) - allows either to register ``Response`` object or directly 75 | provide arguments of ``Response`` object. See `Response Parameters`_ 76 | 77 | .. code-block:: python 78 | 79 | import responses 80 | import requests 81 | 82 | 83 | @responses.activate 84 | def test_simple(): 85 | # Register via 'Response' object 86 | rsp1 = responses.Response( 87 | method="PUT", 88 | url="http://example.com", 89 | ) 90 | responses.add(rsp1) 91 | # register via direct arguments 92 | responses.add( 93 | responses.GET, 94 | "http://twitter.com/api/1/foobar", 95 | json={"error": "not found"}, 96 | status=404, 97 | ) 98 | 99 | resp = requests.get("http://twitter.com/api/1/foobar") 100 | resp2 = requests.put("http://example.com") 101 | 102 | assert resp.json() == {"error": "not found"} 103 | assert resp.status_code == 404 104 | 105 | assert resp2.status_code == 200 106 | assert resp2.request.method == "PUT" 107 | 108 | 109 | If you attempt to fetch a url which doesn't hit a match, ``responses`` will raise 110 | a ``ConnectionError``: 111 | 112 | .. code-block:: python 113 | 114 | import responses 115 | import requests 116 | 117 | from requests.exceptions import ConnectionError 118 | 119 | 120 | @responses.activate 121 | def test_simple(): 122 | with pytest.raises(ConnectionError): 123 | requests.get("http://twitter.com/api/1/foobar") 124 | 125 | 126 | Shortcuts 127 | ^^^^^^^^^ 128 | 129 | Shortcuts provide a shorten version of ``responses.add()`` where method argument is prefilled 130 | 131 | * responses.delete(``Response args``) - register DELETE response 132 | * responses.get(``Response args``) - register GET response 133 | * responses.head(``Response args``) - register HEAD response 134 | * responses.options(``Response args``) - register OPTIONS response 135 | * responses.patch(``Response args``) - register PATCH response 136 | * responses.post(``Response args``) - register POST response 137 | * responses.put(``Response args``) - register PUT response 138 | 139 | .. code-block:: python 140 | 141 | import responses 142 | import requests 143 | 144 | 145 | @responses.activate 146 | def test_simple(): 147 | responses.get( 148 | "http://twitter.com/api/1/foobar", 149 | json={"type": "get"}, 150 | ) 151 | 152 | responses.post( 153 | "http://twitter.com/api/1/foobar", 154 | json={"type": "post"}, 155 | ) 156 | 157 | responses.patch( 158 | "http://twitter.com/api/1/foobar", 159 | json={"type": "patch"}, 160 | ) 161 | 162 | resp_get = requests.get("http://twitter.com/api/1/foobar") 163 | resp_post = requests.post("http://twitter.com/api/1/foobar") 164 | resp_patch = requests.patch("http://twitter.com/api/1/foobar") 165 | 166 | assert resp_get.json() == {"type": "get"} 167 | assert resp_post.json() == {"type": "post"} 168 | assert resp_patch.json() == {"type": "patch"} 169 | 170 | Responses as a context manager 171 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 172 | 173 | Instead of wrapping the whole function with decorator you can use a context manager. 174 | 175 | .. code-block:: python 176 | 177 | import responses 178 | import requests 179 | 180 | 181 | def test_my_api(): 182 | with responses.RequestsMock() as rsps: 183 | rsps.add( 184 | responses.GET, 185 | "http://twitter.com/api/1/foobar", 186 | body="{}", 187 | status=200, 188 | content_type="application/json", 189 | ) 190 | resp = requests.get("http://twitter.com/api/1/foobar") 191 | 192 | assert resp.status_code == 200 193 | 194 | # outside the context manager requests will hit the remote server 195 | resp = requests.get("http://twitter.com/api/1/foobar") 196 | resp.status_code == 404 197 | 198 | 199 | Response Parameters 200 | ------------------- 201 | 202 | The following attributes can be passed to a Response mock: 203 | 204 | method (``str``) 205 | The HTTP method (GET, POST, etc). 206 | 207 | url (``str`` or ``compiled regular expression``) 208 | The full resource URL. 209 | 210 | match_querystring (``bool``) 211 | DEPRECATED: Use ``responses.matchers.query_param_matcher`` or 212 | ``responses.matchers.query_string_matcher`` 213 | 214 | Include the query string when matching requests. 215 | Enabled by default if the response URL contains a query string, 216 | disabled if it doesn't or the URL is a regular expression. 217 | 218 | body (``str`` or ``BufferedReader`` or ``Exception``) 219 | The response body. Read more `Exception as Response body`_ 220 | 221 | json 222 | A Python object representing the JSON response body. Automatically configures 223 | the appropriate Content-Type. 224 | 225 | status (``int``) 226 | The HTTP status code. 227 | 228 | content_type (``content_type``) 229 | Defaults to ``text/plain``. 230 | 231 | headers (``dict``) 232 | Response headers. 233 | 234 | stream (``bool``) 235 | DEPRECATED: use ``stream`` argument in request directly 236 | 237 | auto_calculate_content_length (``bool``) 238 | Disabled by default. Automatically calculates the length of a supplied string or JSON body. 239 | 240 | match (``tuple``) 241 | An iterable (``tuple`` is recommended) of callbacks to match requests 242 | based on request attributes. 243 | Current module provides multiple matchers that you can use to match: 244 | 245 | * body contents in JSON format 246 | * body contents in URL encoded data format 247 | * request query parameters 248 | * request query string (similar to query parameters but takes string as input) 249 | * kwargs provided to request e.g. ``stream``, ``verify`` 250 | * 'multipart/form-data' content and headers in request 251 | * request headers 252 | * request fragment identifier 253 | 254 | Alternatively user can create custom matcher. 255 | Read more `Matching Requests`_ 256 | 257 | 258 | Exception as Response body 259 | -------------------------- 260 | 261 | You can pass an ``Exception`` as the body to trigger an error on the request: 262 | 263 | .. code-block:: python 264 | 265 | import responses 266 | import requests 267 | 268 | 269 | @responses.activate 270 | def test_simple(): 271 | responses.get("http://twitter.com/api/1/foobar", body=Exception("...")) 272 | with pytest.raises(Exception): 273 | requests.get("http://twitter.com/api/1/foobar") 274 | 275 | 276 | Matching Requests 277 | ----------------- 278 | 279 | Matching Request Body Contents 280 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 281 | 282 | When adding responses for endpoints that are sent request data you can add 283 | matchers to ensure your code is sending the right parameters and provide 284 | different responses based on the request body contents. ``responses`` provides 285 | matchers for JSON and URL-encoded request bodies. 286 | 287 | URL-encoded data 288 | """""""""""""""" 289 | 290 | .. code-block:: python 291 | 292 | import responses 293 | import requests 294 | from responses import matchers 295 | 296 | 297 | @responses.activate 298 | def test_calc_api(): 299 | responses.post( 300 | url="http://calc.com/sum", 301 | body="4", 302 | match=[matchers.urlencoded_params_matcher({"left": "1", "right": "3"})], 303 | ) 304 | requests.post("http://calc.com/sum", data={"left": 1, "right": 3}) 305 | 306 | 307 | JSON encoded data 308 | """"""""""""""""" 309 | 310 | Matching JSON encoded data can be done with ``matchers.json_params_matcher()``. 311 | 312 | .. code-block:: python 313 | 314 | import responses 315 | import requests 316 | from responses import matchers 317 | 318 | 319 | @responses.activate 320 | def test_calc_api(): 321 | responses.post( 322 | url="http://example.com/", 323 | body="one", 324 | match=[ 325 | matchers.json_params_matcher({"page": {"name": "first", "type": "json"}}) 326 | ], 327 | ) 328 | resp = requests.request( 329 | "POST", 330 | "http://example.com/", 331 | headers={"Content-Type": "application/json"}, 332 | json={"page": {"name": "first", "type": "json"}}, 333 | ) 334 | 335 | 336 | Query Parameters Matcher 337 | ^^^^^^^^^^^^^^^^^^^^^^^^ 338 | 339 | Query Parameters as a Dictionary 340 | """""""""""""""""""""""""""""""" 341 | 342 | You can use the ``matchers.query_param_matcher`` function to match 343 | against the ``params`` request parameter. Just use the same dictionary as you 344 | will use in ``params`` argument in ``request``. 345 | 346 | Note, do not use query parameters as part of the URL. Avoid using ``match_querystring`` 347 | deprecated argument. 348 | 349 | .. code-block:: python 350 | 351 | import responses 352 | import requests 353 | from responses import matchers 354 | 355 | 356 | @responses.activate 357 | def test_calc_api(): 358 | url = "http://example.com/test" 359 | params = {"hello": "world", "I am": "a big test"} 360 | responses.get( 361 | url=url, 362 | body="test", 363 | match=[matchers.query_param_matcher(params)], 364 | ) 365 | 366 | resp = requests.get(url, params=params) 367 | 368 | constructed_url = r"http://example.com/test?I+am=a+big+test&hello=world" 369 | assert resp.url == constructed_url 370 | assert resp.request.url == constructed_url 371 | assert resp.request.params == params 372 | 373 | By default, matcher will validate that all parameters match strictly. 374 | To validate that only parameters specified in the matcher are present in original request 375 | use ``strict_match=False``. 376 | 377 | Query Parameters as a String 378 | """""""""""""""""""""""""""" 379 | 380 | As alternative, you can use query string value in ``matchers.query_string_matcher`` to match 381 | query parameters in your request 382 | 383 | .. code-block:: python 384 | 385 | import requests 386 | import responses 387 | from responses import matchers 388 | 389 | 390 | @responses.activate 391 | def my_func(): 392 | responses.get( 393 | "https://httpbin.org/get", 394 | match=[matchers.query_string_matcher("didi=pro&test=1")], 395 | ) 396 | resp = requests.get("https://httpbin.org/get", params={"test": 1, "didi": "pro"}) 397 | 398 | 399 | my_func() 400 | 401 | 402 | Request Keyword Arguments Matcher 403 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 404 | 405 | To validate request arguments use the ``matchers.request_kwargs_matcher`` function to match 406 | against the request kwargs. 407 | 408 | Only following arguments are supported: ``timeout``, ``verify``, ``proxies``, ``stream``, ``cert``. 409 | 410 | Note, only arguments provided to ``matchers.request_kwargs_matcher`` will be validated. 411 | 412 | .. code-block:: python 413 | 414 | import responses 415 | import requests 416 | from responses import matchers 417 | 418 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 419 | req_kwargs = { 420 | "stream": True, 421 | "verify": False, 422 | } 423 | rsps.add( 424 | "GET", 425 | "http://111.com", 426 | match=[matchers.request_kwargs_matcher(req_kwargs)], 427 | ) 428 | 429 | requests.get("http://111.com", stream=True) 430 | 431 | # >>> Arguments don't match: {stream: True, verify: True} doesn't match {stream: True, verify: False} 432 | 433 | 434 | Request multipart/form-data Data Validation 435 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 436 | 437 | To validate request body and headers for ``multipart/form-data`` data you can use 438 | ``matchers.multipart_matcher``. The ``data``, and ``files`` parameters provided will be compared 439 | to the request: 440 | 441 | .. code-block:: python 442 | 443 | import requests 444 | import responses 445 | from responses.matchers import multipart_matcher 446 | 447 | 448 | @responses.activate 449 | def my_func(): 450 | req_data = {"some": "other", "data": "fields"} 451 | req_files = {"file_name": b"Old World!"} 452 | responses.post( 453 | url="http://httpbin.org/post", 454 | match=[multipart_matcher(req_files, data=req_data)], 455 | ) 456 | resp = requests.post("http://httpbin.org/post", files={"file_name": b"New World!"}) 457 | 458 | 459 | my_func() 460 | # >>> raises ConnectionError: multipart/form-data doesn't match. Request body differs. 461 | 462 | Request Fragment Identifier Validation 463 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 464 | 465 | To validate request URL fragment identifier you can use ``matchers.fragment_identifier_matcher``. 466 | The matcher takes fragment string (everything after ``#`` sign) as input for comparison: 467 | 468 | .. code-block:: python 469 | 470 | import requests 471 | import responses 472 | from responses.matchers import fragment_identifier_matcher 473 | 474 | 475 | @responses.activate 476 | def run(): 477 | url = "http://example.com?ab=xy&zed=qwe#test=1&foo=bar" 478 | responses.get( 479 | url, 480 | match=[fragment_identifier_matcher("test=1&foo=bar")], 481 | body=b"test", 482 | ) 483 | 484 | # two requests to check reversed order of fragment identifier 485 | resp = requests.get("http://example.com?ab=xy&zed=qwe#test=1&foo=bar") 486 | resp = requests.get("http://example.com?zed=qwe&ab=xy#foo=bar&test=1") 487 | 488 | 489 | run() 490 | 491 | Request Headers Validation 492 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 493 | 494 | When adding responses you can specify matchers to ensure that your code is 495 | sending the right headers and provide different responses based on the request 496 | headers. 497 | 498 | .. code-block:: python 499 | 500 | import responses 501 | import requests 502 | from responses import matchers 503 | 504 | 505 | @responses.activate 506 | def test_content_type(): 507 | responses.get( 508 | url="http://example.com/", 509 | body="hello world", 510 | match=[matchers.header_matcher({"Accept": "text/plain"})], 511 | ) 512 | 513 | responses.get( 514 | url="http://example.com/", 515 | json={"content": "hello world"}, 516 | match=[matchers.header_matcher({"Accept": "application/json"})], 517 | ) 518 | 519 | # request in reverse order to how they were added! 520 | resp = requests.get("http://example.com/", headers={"Accept": "application/json"}) 521 | assert resp.json() == {"content": "hello world"} 522 | 523 | resp = requests.get("http://example.com/", headers={"Accept": "text/plain"}) 524 | assert resp.text == "hello world" 525 | 526 | Because ``requests`` will send several standard headers in addition to what was 527 | specified by your code, request headers that are additional to the ones 528 | passed to the matcher are ignored by default. You can change this behaviour by 529 | passing ``strict_match=True`` to the matcher to ensure that only the headers 530 | that you're expecting are sent and no others. Note that you will probably have 531 | to use a ``PreparedRequest`` in your code to ensure that ``requests`` doesn't 532 | include any additional headers. 533 | 534 | .. code-block:: python 535 | 536 | import responses 537 | import requests 538 | from responses import matchers 539 | 540 | 541 | @responses.activate 542 | def test_content_type(): 543 | responses.get( 544 | url="http://example.com/", 545 | body="hello world", 546 | match=[matchers.header_matcher({"Accept": "text/plain"}, strict_match=True)], 547 | ) 548 | 549 | # this will fail because requests adds its own headers 550 | with pytest.raises(ConnectionError): 551 | requests.get("http://example.com/", headers={"Accept": "text/plain"}) 552 | 553 | # a prepared request where you overwrite the headers before sending will work 554 | session = requests.Session() 555 | prepped = session.prepare_request( 556 | requests.Request( 557 | method="GET", 558 | url="http://example.com/", 559 | ) 560 | ) 561 | prepped.headers = {"Accept": "text/plain"} 562 | 563 | resp = session.send(prepped) 564 | assert resp.text == "hello world" 565 | 566 | 567 | Creating Custom Matcher 568 | ^^^^^^^^^^^^^^^^^^^^^^^ 569 | 570 | If your application requires other encodings or different data validation you can build 571 | your own matcher that returns ``Tuple[matches: bool, reason: str]``. 572 | Where boolean represents ``True`` or ``False`` if the request parameters match and 573 | the string is a reason in case of match failure. Your matcher can 574 | expect a ``PreparedRequest`` parameter to be provided by ``responses``. 575 | 576 | Note, ``PreparedRequest`` is customized and has additional attributes ``params`` and ``req_kwargs``. 577 | 578 | Response Registry 579 | --------------------------- 580 | 581 | Default Registry 582 | ^^^^^^^^^^^^^^^^ 583 | 584 | By default, ``responses`` will search all registered ``Response`` objects and 585 | return a match. If only one ``Response`` is registered, the registry is kept unchanged. 586 | However, if multiple matches are found for the same request, then first match is returned and 587 | removed from registry. 588 | 589 | Ordered Registry 590 | ^^^^^^^^^^^^^^^^ 591 | 592 | In some scenarios it is important to preserve the order of the requests and responses. 593 | You can use ``registries.OrderedRegistry`` to force all ``Response`` objects to be dependent 594 | on the insertion order and invocation index. 595 | In following example we add multiple ``Response`` objects that target the same URL. However, 596 | you can see, that status code will depend on the invocation order. 597 | 598 | 599 | .. code-block:: python 600 | 601 | import requests 602 | 603 | import responses 604 | from responses.registries import OrderedRegistry 605 | 606 | 607 | @responses.activate(registry=OrderedRegistry) 608 | def test_invocation_index(): 609 | responses.get( 610 | "http://twitter.com/api/1/foobar", 611 | json={"msg": "not found"}, 612 | status=404, 613 | ) 614 | responses.get( 615 | "http://twitter.com/api/1/foobar", 616 | json={"msg": "OK"}, 617 | status=200, 618 | ) 619 | responses.get( 620 | "http://twitter.com/api/1/foobar", 621 | json={"msg": "OK"}, 622 | status=200, 623 | ) 624 | responses.get( 625 | "http://twitter.com/api/1/foobar", 626 | json={"msg": "not found"}, 627 | status=404, 628 | ) 629 | 630 | resp = requests.get("http://twitter.com/api/1/foobar") 631 | assert resp.status_code == 404 632 | resp = requests.get("http://twitter.com/api/1/foobar") 633 | assert resp.status_code == 200 634 | resp = requests.get("http://twitter.com/api/1/foobar") 635 | assert resp.status_code == 200 636 | resp = requests.get("http://twitter.com/api/1/foobar") 637 | assert resp.status_code == 404 638 | 639 | 640 | Custom Registry 641 | ^^^^^^^^^^^^^^^ 642 | 643 | Built-in ``registries`` are suitable for most of use cases, but to handle special conditions, you can 644 | implement custom registry which must follow interface of ``registries.FirstMatchRegistry``. 645 | Redefining the ``find`` method will allow you to create custom search logic and return 646 | appropriate ``Response`` 647 | 648 | Example that shows how to set custom registry 649 | 650 | .. code-block:: python 651 | 652 | import responses 653 | from responses import registries 654 | 655 | 656 | class CustomRegistry(registries.FirstMatchRegistry): 657 | pass 658 | 659 | 660 | print("Before tests:", responses.mock.get_registry()) 661 | """ Before tests: """ 662 | 663 | 664 | # using function decorator 665 | @responses.activate(registry=CustomRegistry) 666 | def run(): 667 | print("Within test:", responses.mock.get_registry()) 668 | """ Within test: <__main__.CustomRegistry object> """ 669 | 670 | 671 | run() 672 | 673 | print("After test:", responses.mock.get_registry()) 674 | """ After test: """ 675 | 676 | # using context manager 677 | with responses.RequestsMock(registry=CustomRegistry) as rsps: 678 | print("In context manager:", rsps.get_registry()) 679 | """ In context manager: <__main__.CustomRegistry object> """ 680 | 681 | print("After exit from context manager:", responses.mock.get_registry()) 682 | """ 683 | After exit from context manager: 684 | """ 685 | 686 | Dynamic Responses 687 | ----------------- 688 | 689 | You can utilize callbacks to provide dynamic responses. The callback must return 690 | a tuple of (``status``, ``headers``, ``body``). 691 | 692 | .. code-block:: python 693 | 694 | import json 695 | 696 | import responses 697 | import requests 698 | 699 | 700 | @responses.activate 701 | def test_calc_api(): 702 | def request_callback(request): 703 | payload = json.loads(request.body) 704 | resp_body = {"value": sum(payload["numbers"])} 705 | headers = {"request-id": "728d329e-0e86-11e4-a748-0c84dc037c13"} 706 | return (200, headers, json.dumps(resp_body)) 707 | 708 | responses.add_callback( 709 | responses.POST, 710 | "http://calc.com/sum", 711 | callback=request_callback, 712 | content_type="application/json", 713 | ) 714 | 715 | resp = requests.post( 716 | "http://calc.com/sum", 717 | json.dumps({"numbers": [1, 2, 3]}), 718 | headers={"content-type": "application/json"}, 719 | ) 720 | 721 | assert resp.json() == {"value": 6} 722 | 723 | assert len(responses.calls) == 1 724 | assert responses.calls[0].request.url == "http://calc.com/sum" 725 | assert responses.calls[0].response.text == '{"value": 6}' 726 | assert ( 727 | responses.calls[0].response.headers["request-id"] 728 | == "728d329e-0e86-11e4-a748-0c84dc037c13" 729 | ) 730 | 731 | You can also pass a compiled regex to ``add_callback`` to match multiple urls: 732 | 733 | .. code-block:: python 734 | 735 | import re, json 736 | 737 | from functools import reduce 738 | 739 | import responses 740 | import requests 741 | 742 | operators = { 743 | "sum": lambda x, y: x + y, 744 | "prod": lambda x, y: x * y, 745 | "pow": lambda x, y: x**y, 746 | } 747 | 748 | 749 | @responses.activate 750 | def test_regex_url(): 751 | def request_callback(request): 752 | payload = json.loads(request.body) 753 | operator_name = request.path_url[1:] 754 | 755 | operator = operators[operator_name] 756 | 757 | resp_body = {"value": reduce(operator, payload["numbers"])} 758 | headers = {"request-id": "728d329e-0e86-11e4-a748-0c84dc037c13"} 759 | return (200, headers, json.dumps(resp_body)) 760 | 761 | responses.add_callback( 762 | responses.POST, 763 | re.compile("http://calc.com/(sum|prod|pow|unsupported)"), 764 | callback=request_callback, 765 | content_type="application/json", 766 | ) 767 | 768 | resp = requests.post( 769 | "http://calc.com/prod", 770 | json.dumps({"numbers": [2, 3, 4]}), 771 | headers={"content-type": "application/json"}, 772 | ) 773 | assert resp.json() == {"value": 24} 774 | 775 | 776 | test_regex_url() 777 | 778 | 779 | If you want to pass extra keyword arguments to the callback function, for example when reusing 780 | a callback function to give a slightly different result, you can use ``functools.partial``: 781 | 782 | .. code-block:: python 783 | 784 | from functools import partial 785 | 786 | 787 | def request_callback(request, id=None): 788 | payload = json.loads(request.body) 789 | resp_body = {"value": sum(payload["numbers"])} 790 | headers = {"request-id": id} 791 | return (200, headers, json.dumps(resp_body)) 792 | 793 | 794 | responses.add_callback( 795 | responses.POST, 796 | "http://calc.com/sum", 797 | callback=partial(request_callback, id="728d329e-0e86-11e4-a748-0c84dc037c13"), 798 | content_type="application/json", 799 | ) 800 | 801 | 802 | Integration with unit test frameworks 803 | ------------------------------------- 804 | 805 | Responses as a ``pytest`` fixture 806 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 807 | 808 | Use the pytest-responses package to export ``responses`` as a pytest fixture. 809 | 810 | ``pip install pytest-responses`` 811 | 812 | You can then access it in a pytest script using: 813 | 814 | .. code-block:: python 815 | 816 | import pytest_responses 817 | 818 | 819 | def test_api(responses): 820 | responses.get( 821 | "http://twitter.com/api/1/foobar", 822 | body="{}", 823 | status=200, 824 | content_type="application/json", 825 | ) 826 | resp = requests.get("http://twitter.com/api/1/foobar") 827 | assert resp.status_code == 200 828 | 829 | Add default responses for each test 830 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 831 | 832 | When run with ``unittest`` tests, this can be used to set up some 833 | generic class-level responses, that may be complemented by each test. 834 | Similar interface could be applied in ``pytest`` framework. 835 | 836 | .. code-block:: python 837 | 838 | class TestMyApi(unittest.TestCase): 839 | def setUp(self): 840 | responses.get("https://example.com", body="within setup") 841 | # here go other self.responses.add(...) 842 | 843 | @responses.activate 844 | def test_my_func(self): 845 | responses.get( 846 | "https://httpbin.org/get", 847 | match=[matchers.query_param_matcher({"test": "1", "didi": "pro"})], 848 | body="within test", 849 | ) 850 | resp = requests.get("https://example.com") 851 | resp2 = requests.get( 852 | "https://httpbin.org/get", params={"test": "1", "didi": "pro"} 853 | ) 854 | print(resp.text) 855 | # >>> within setup 856 | print(resp2.text) 857 | # >>> within test 858 | 859 | 860 | RequestMock methods: start, stop, reset 861 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 862 | 863 | ``responses`` has ``start``, ``stop``, ``reset`` methods very analogous to 864 | `unittest.mock.patch `_. 865 | These make it simpler to do requests mocking in ``setup`` methods or where 866 | you want to do multiple patches without nesting decorators or with statements. 867 | 868 | .. code-block:: python 869 | 870 | class TestUnitTestPatchSetup: 871 | def setup(self): 872 | """Creates ``RequestsMock`` instance and starts it.""" 873 | self.r_mock = responses.RequestsMock(assert_all_requests_are_fired=True) 874 | self.r_mock.start() 875 | 876 | # optionally some default responses could be registered 877 | self.r_mock.get("https://example.com", status=505) 878 | self.r_mock.put("https://example.com", status=506) 879 | 880 | def teardown(self): 881 | """Stops and resets RequestsMock instance. 882 | 883 | If ``assert_all_requests_are_fired`` is set to ``True``, will raise an error 884 | if some requests were not processed. 885 | """ 886 | self.r_mock.stop() 887 | self.r_mock.reset() 888 | 889 | def test_function(self): 890 | resp = requests.get("https://example.com") 891 | assert resp.status_code == 505 892 | 893 | resp = requests.put("https://example.com") 894 | assert resp.status_code == 506 895 | 896 | 897 | Assertions on declared responses 898 | -------------------------------- 899 | 900 | When used as a context manager, Responses will, by default, raise an assertion 901 | error if a url was registered but not accessed. This can be disabled by passing 902 | the ``assert_all_requests_are_fired`` value: 903 | 904 | .. code-block:: python 905 | 906 | import responses 907 | import requests 908 | 909 | 910 | def test_my_api(): 911 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 912 | rsps.add( 913 | responses.GET, 914 | "http://twitter.com/api/1/foobar", 915 | body="{}", 916 | status=200, 917 | content_type="application/json", 918 | ) 919 | 920 | Assert Request Call Count 921 | ------------------------- 922 | 923 | Assert based on ``Response`` object 924 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 925 | 926 | Each ``Response`` object has ``call_count`` attribute that could be inspected 927 | to check how many times each request was matched. 928 | 929 | .. code-block:: python 930 | 931 | @responses.activate 932 | def test_call_count_with_matcher(): 933 | rsp = responses.get( 934 | "http://www.example.com", 935 | match=(matchers.query_param_matcher({}),), 936 | ) 937 | rsp2 = responses.get( 938 | "http://www.example.com", 939 | match=(matchers.query_param_matcher({"hello": "world"}),), 940 | status=777, 941 | ) 942 | requests.get("http://www.example.com") 943 | resp1 = requests.get("http://www.example.com") 944 | requests.get("http://www.example.com?hello=world") 945 | resp2 = requests.get("http://www.example.com?hello=world") 946 | 947 | assert resp1.status_code == 200 948 | assert resp2.status_code == 777 949 | 950 | assert rsp.call_count == 2 951 | assert rsp2.call_count == 2 952 | 953 | Assert based on the exact URL 954 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 955 | 956 | Assert that the request was called exactly n times. 957 | 958 | .. code-block:: python 959 | 960 | import responses 961 | import requests 962 | 963 | 964 | @responses.activate 965 | def test_assert_call_count(): 966 | responses.get("http://example.com") 967 | 968 | requests.get("http://example.com") 969 | assert responses.assert_call_count("http://example.com", 1) is True 970 | 971 | requests.get("http://example.com") 972 | with pytest.raises(AssertionError) as excinfo: 973 | responses.assert_call_count("http://example.com", 1) 974 | assert ( 975 | "Expected URL 'http://example.com' to be called 1 times. Called 2 times." 976 | in str(excinfo.value) 977 | ) 978 | 979 | 980 | @responses.activate 981 | def test_assert_call_count_always_match_qs(): 982 | responses.get("http://www.example.com") 983 | requests.get("http://www.example.com") 984 | requests.get("http://www.example.com?hello=world") 985 | 986 | # One call on each url, querystring is matched by default 987 | responses.assert_call_count("http://www.example.com", 1) is True 988 | responses.assert_call_count("http://www.example.com?hello=world", 1) is True 989 | 990 | 991 | Assert Request Calls data 992 | ------------------------- 993 | 994 | ``Request`` object has ``calls`` list which elements correspond to ``Call`` objects 995 | in the global list of ``Registry``. This can be useful when the order of requests is not 996 | guaranteed, but you need to check their correctness, for example in multithreaded 997 | applications. 998 | 999 | .. code-block:: python 1000 | 1001 | import concurrent.futures 1002 | import responses 1003 | import requests 1004 | 1005 | 1006 | @responses.activate 1007 | def test_assert_calls_on_resp(): 1008 | rsp1 = responses.patch("http://www.foo.bar/1/", status=200) 1009 | rsp2 = responses.patch("http://www.foo.bar/2/", status=400) 1010 | rsp3 = responses.patch("http://www.foo.bar/3/", status=200) 1011 | 1012 | def update_user(uid, is_active): 1013 | url = f"http://www.foo.bar/{uid}/" 1014 | response = requests.patch(url, json={"is_active": is_active}) 1015 | return response 1016 | 1017 | with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: 1018 | future_to_uid = { 1019 | executor.submit(update_user, uid, is_active): uid 1020 | for (uid, is_active) in [("3", True), ("2", True), ("1", False)] 1021 | } 1022 | for future in concurrent.futures.as_completed(future_to_uid): 1023 | uid = future_to_uid[future] 1024 | response = future.result() 1025 | print(f"{uid} updated with {response.status_code} status code") 1026 | 1027 | assert len(responses.calls) == 3 # total calls count 1028 | 1029 | assert rsp1.call_count == 1 1030 | assert rsp1.calls[0] in responses.calls 1031 | assert rsp1.calls[0].response.status_code == 200 1032 | assert json.loads(rsp1.calls[0].request.body) == {"is_active": False} 1033 | 1034 | assert rsp2.call_count == 1 1035 | assert rsp2.calls[0] in responses.calls 1036 | assert rsp2.calls[0].response.status_code == 400 1037 | assert json.loads(rsp2.calls[0].request.body) == {"is_active": True} 1038 | 1039 | assert rsp3.call_count == 1 1040 | assert rsp3.calls[0] in responses.calls 1041 | assert rsp3.calls[0].response.status_code == 200 1042 | assert json.loads(rsp3.calls[0].request.body) == {"is_active": True} 1043 | 1044 | Multiple Responses 1045 | ------------------ 1046 | 1047 | You can also add multiple responses for the same url: 1048 | 1049 | .. code-block:: python 1050 | 1051 | import responses 1052 | import requests 1053 | 1054 | 1055 | @responses.activate 1056 | def test_my_api(): 1057 | responses.get("http://twitter.com/api/1/foobar", status=500) 1058 | responses.get( 1059 | "http://twitter.com/api/1/foobar", 1060 | body="{}", 1061 | status=200, 1062 | content_type="application/json", 1063 | ) 1064 | 1065 | resp = requests.get("http://twitter.com/api/1/foobar") 1066 | assert resp.status_code == 500 1067 | resp = requests.get("http://twitter.com/api/1/foobar") 1068 | assert resp.status_code == 200 1069 | 1070 | 1071 | URL Redirection 1072 | --------------- 1073 | 1074 | In the following example you can see how to create a redirection chain and add custom exception that will be raised 1075 | in the execution chain and contain the history of redirects. 1076 | 1077 | .. code-block:: 1078 | 1079 | A -> 301 redirect -> B 1080 | B -> 301 redirect -> C 1081 | C -> connection issue 1082 | 1083 | .. code-block:: python 1084 | 1085 | import pytest 1086 | import requests 1087 | 1088 | import responses 1089 | 1090 | 1091 | @responses.activate 1092 | def test_redirect(): 1093 | # create multiple Response objects where first two contain redirect headers 1094 | rsp1 = responses.Response( 1095 | responses.GET, 1096 | "http://example.com/1", 1097 | status=301, 1098 | headers={"Location": "http://example.com/2"}, 1099 | ) 1100 | rsp2 = responses.Response( 1101 | responses.GET, 1102 | "http://example.com/2", 1103 | status=301, 1104 | headers={"Location": "http://example.com/3"}, 1105 | ) 1106 | rsp3 = responses.Response(responses.GET, "http://example.com/3", status=200) 1107 | 1108 | # register above generated Responses in ``response`` module 1109 | responses.add(rsp1) 1110 | responses.add(rsp2) 1111 | responses.add(rsp3) 1112 | 1113 | # do the first request in order to generate genuine ``requests`` response 1114 | # this object will contain genuine attributes of the response, like ``history`` 1115 | rsp = requests.get("http://example.com/1") 1116 | responses.calls.reset() 1117 | 1118 | # customize exception with ``response`` attribute 1119 | my_error = requests.ConnectionError("custom error") 1120 | my_error.response = rsp 1121 | 1122 | # update body of the 3rd response with Exception, this will be raised during execution 1123 | rsp3.body = my_error 1124 | 1125 | with pytest.raises(requests.ConnectionError) as exc_info: 1126 | requests.get("http://example.com/1") 1127 | 1128 | assert exc_info.value.args[0] == "custom error" 1129 | assert rsp1.url in exc_info.value.response.history[0].url 1130 | assert rsp2.url in exc_info.value.response.history[1].url 1131 | 1132 | 1133 | Validate ``Retry`` mechanism 1134 | ---------------------------- 1135 | 1136 | If you are using the ``Retry`` features of ``urllib3`` and want to cover scenarios that test your retry limits, you can test those scenarios with ``responses`` as well. The best approach will be to use an `Ordered Registry`_ 1137 | 1138 | .. code-block:: python 1139 | 1140 | import requests 1141 | 1142 | import responses 1143 | from responses import registries 1144 | from urllib3.util import Retry 1145 | 1146 | 1147 | @responses.activate(registry=registries.OrderedRegistry) 1148 | def test_max_retries(): 1149 | url = "https://example.com" 1150 | rsp1 = responses.get(url, body="Error", status=500) 1151 | rsp2 = responses.get(url, body="Error", status=500) 1152 | rsp3 = responses.get(url, body="Error", status=500) 1153 | rsp4 = responses.get(url, body="OK", status=200) 1154 | 1155 | session = requests.Session() 1156 | 1157 | adapter = requests.adapters.HTTPAdapter( 1158 | max_retries=Retry( 1159 | total=4, 1160 | backoff_factor=0.1, 1161 | status_forcelist=[500], 1162 | method_whitelist=["GET", "POST", "PATCH"], 1163 | ) 1164 | ) 1165 | session.mount("https://", adapter) 1166 | 1167 | resp = session.get(url) 1168 | 1169 | assert resp.status_code == 200 1170 | assert rsp1.call_count == 1 1171 | assert rsp2.call_count == 1 1172 | assert rsp3.call_count == 1 1173 | assert rsp4.call_count == 1 1174 | 1175 | 1176 | Using a callback to modify the response 1177 | --------------------------------------- 1178 | 1179 | If you use customized processing in ``requests`` via subclassing/mixins, or if you 1180 | have library tools that interact with ``requests`` at a low level, you may need 1181 | to add extended processing to the mocked Response object to fully simulate the 1182 | environment for your tests. A ``response_callback`` can be used, which will be 1183 | wrapped by the library before being returned to the caller. The callback 1184 | accepts a ``response`` as it's single argument, and is expected to return a 1185 | single ``response`` object. 1186 | 1187 | .. code-block:: python 1188 | 1189 | import responses 1190 | import requests 1191 | 1192 | 1193 | def response_callback(resp): 1194 | resp.callback_processed = True 1195 | return resp 1196 | 1197 | 1198 | with responses.RequestsMock(response_callback=response_callback) as m: 1199 | m.add(responses.GET, "http://example.com", body=b"test") 1200 | resp = requests.get("http://example.com") 1201 | assert resp.text == "test" 1202 | assert hasattr(resp, "callback_processed") 1203 | assert resp.callback_processed is True 1204 | 1205 | 1206 | Passing through real requests 1207 | ----------------------------- 1208 | 1209 | In some cases you may wish to allow for certain requests to pass through responses 1210 | and hit a real server. This can be done with the ``add_passthru`` methods: 1211 | 1212 | .. code-block:: python 1213 | 1214 | import responses 1215 | 1216 | 1217 | @responses.activate 1218 | def test_my_api(): 1219 | responses.add_passthru("https://percy.io") 1220 | 1221 | This will allow any requests matching that prefix, that is otherwise not 1222 | registered as a mock response, to passthru using the standard behavior. 1223 | 1224 | Pass through endpoints can be configured with regex patterns if you 1225 | need to allow an entire domain or path subtree to send requests: 1226 | 1227 | .. code-block:: python 1228 | 1229 | responses.add_passthru(re.compile("https://percy.io/\\w+")) 1230 | 1231 | 1232 | Lastly, you can use the ``passthrough`` argument of the ``Response`` object 1233 | to force a response to behave as a pass through. 1234 | 1235 | .. code-block:: python 1236 | 1237 | # Enable passthrough for a single response 1238 | response = Response( 1239 | responses.GET, 1240 | "http://example.com", 1241 | body="not used", 1242 | passthrough=True, 1243 | ) 1244 | responses.add(response) 1245 | 1246 | # Use PassthroughResponse 1247 | response = PassthroughResponse(responses.GET, "http://example.com") 1248 | responses.add(response) 1249 | 1250 | Viewing/Modifying registered responses 1251 | -------------------------------------- 1252 | 1253 | Registered responses are available as a public method of the RequestMock 1254 | instance. It is sometimes useful for debugging purposes to view the stack of 1255 | registered responses which can be accessed via ``responses.registered()``. 1256 | 1257 | The ``replace`` function allows a previously registered ``response`` to be 1258 | changed. The method signature is identical to ``add``. ``response`` s are 1259 | identified using ``method`` and ``url``. Only the first matched ``response`` is 1260 | replaced. 1261 | 1262 | .. code-block:: python 1263 | 1264 | import responses 1265 | import requests 1266 | 1267 | 1268 | @responses.activate 1269 | def test_replace(): 1270 | responses.get("http://example.org", json={"data": 1}) 1271 | responses.replace(responses.GET, "http://example.org", json={"data": 2}) 1272 | 1273 | resp = requests.get("http://example.org") 1274 | 1275 | assert resp.json() == {"data": 2} 1276 | 1277 | 1278 | The ``upsert`` function allows a previously registered ``response`` to be 1279 | changed like ``replace``. If the response is registered, the ``upsert`` function 1280 | will registered it like ``add``. 1281 | 1282 | ``remove`` takes a ``method`` and ``url`` argument and will remove **all** 1283 | matched responses from the registered list. 1284 | 1285 | Finally, ``reset`` will reset all registered responses. 1286 | 1287 | Coroutines and Multithreading 1288 | ----------------------------- 1289 | 1290 | ``responses`` supports both Coroutines and Multithreading out of the box. 1291 | Note, ``responses`` locks threading on ``RequestMock`` object allowing only 1292 | single thread to access it. 1293 | 1294 | .. code-block:: python 1295 | 1296 | async def test_async_calls(): 1297 | @responses.activate 1298 | async def run(): 1299 | responses.get( 1300 | "http://twitter.com/api/1/foobar", 1301 | json={"error": "not found"}, 1302 | status=404, 1303 | ) 1304 | 1305 | resp = requests.get("http://twitter.com/api/1/foobar") 1306 | assert resp.json() == {"error": "not found"} 1307 | assert responses.calls[0].request.url == "http://twitter.com/api/1/foobar" 1308 | 1309 | await run() 1310 | 1311 | BETA Features 1312 | ------------- 1313 | Below you can find a list of BETA features. Although we will try to keep the API backwards compatible 1314 | with released version, we reserve the right to change these APIs before they are considered stable. Please share your feedback via 1315 | `GitHub Issues `_. 1316 | 1317 | Record Responses to files 1318 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 1319 | 1320 | You can perform real requests to the server and ``responses`` will automatically record the output to the 1321 | file. Recorded data is stored in `YAML `_ format. 1322 | 1323 | Apply ``@responses._recorder.record(file_path="out.yaml")`` decorator to any function where you perform 1324 | requests to record responses to ``out.yaml`` file. 1325 | 1326 | Following code 1327 | 1328 | .. code-block:: python 1329 | 1330 | import requests 1331 | from responses import _recorder 1332 | 1333 | 1334 | def another(): 1335 | rsp = requests.get("https://httpstat.us/500") 1336 | rsp = requests.get("https://httpstat.us/202") 1337 | 1338 | 1339 | @_recorder.record(file_path="out.yaml") 1340 | def test_recorder(): 1341 | rsp = requests.get("https://httpstat.us/404") 1342 | rsp = requests.get("https://httpbin.org/status/wrong") 1343 | another() 1344 | 1345 | will produce next output: 1346 | 1347 | .. code-block:: yaml 1348 | 1349 | responses: 1350 | - response: 1351 | auto_calculate_content_length: false 1352 | body: 404 Not Found 1353 | content_type: text/plain 1354 | method: GET 1355 | status: 404 1356 | url: https://httpstat.us/404 1357 | - response: 1358 | auto_calculate_content_length: false 1359 | body: Invalid status code 1360 | content_type: text/plain 1361 | method: GET 1362 | status: 400 1363 | url: https://httpbin.org/status/wrong 1364 | - response: 1365 | auto_calculate_content_length: false 1366 | body: 500 Internal Server Error 1367 | content_type: text/plain 1368 | method: GET 1369 | status: 500 1370 | url: https://httpstat.us/500 1371 | - response: 1372 | auto_calculate_content_length: false 1373 | body: 202 Accepted 1374 | content_type: text/plain 1375 | method: GET 1376 | status: 202 1377 | url: https://httpstat.us/202 1378 | 1379 | If you are in the REPL, you can also activete the recorder for all following responses: 1380 | 1381 | .. code-block:: python 1382 | 1383 | import requests 1384 | from responses import _recorder 1385 | 1386 | _recorder.recorder.start() 1387 | 1388 | requests.get("https://httpstat.us/500") 1389 | 1390 | _recorder.recorder.dump_to_file("out.yaml") 1391 | 1392 | # you can stop or reset the recorder 1393 | _recorder.recorder.stop() 1394 | _recorder.recorder.reset() 1395 | 1396 | Replay responses (populate registry) from files 1397 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 1398 | 1399 | You can populate your active registry from a ``yaml`` file with recorded responses. 1400 | (See `Record Responses to files`_ to understand how to obtain a file). 1401 | To do that you need to execute ``responses._add_from_file(file_path="out.yaml")`` within 1402 | an activated decorator or a context manager. 1403 | 1404 | The following code example registers a ``patch`` response, then all responses present in 1405 | ``out.yaml`` file and a ``post`` response at the end. 1406 | 1407 | .. code-block:: python 1408 | 1409 | import responses 1410 | 1411 | 1412 | @responses.activate 1413 | def run(): 1414 | responses.patch("http://httpbin.org") 1415 | responses._add_from_file(file_path="out.yaml") 1416 | responses.post("http://httpbin.org/form") 1417 | 1418 | 1419 | run() 1420 | 1421 | 1422 | Contributing 1423 | ------------ 1424 | 1425 | Environment Configuration 1426 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 1427 | 1428 | Responses uses several linting and autoformatting utilities, so it's important that when 1429 | submitting patches you use the appropriate toolchain: 1430 | 1431 | Clone the repository: 1432 | 1433 | .. code-block:: shell 1434 | 1435 | git clone https://github.com/getsentry/responses.git 1436 | 1437 | Create an environment (e.g. with ``virtualenv``): 1438 | 1439 | .. code-block:: shell 1440 | 1441 | virtualenv .env && source .env/bin/activate 1442 | 1443 | Configure development requirements: 1444 | 1445 | .. code-block:: shell 1446 | 1447 | make develop 1448 | 1449 | 1450 | Tests and Code Quality Validation 1451 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 1452 | 1453 | The easiest way to validate your code is to run tests via ``tox``. 1454 | Current ``tox`` configuration runs the same checks that are used in 1455 | GitHub Actions CI/CD pipeline. 1456 | 1457 | Please execute the following command line from the project root to validate 1458 | your code against: 1459 | 1460 | * Unit tests in all Python versions that are supported by this project 1461 | * Type validation via ``mypy`` 1462 | * All ``pre-commit`` hooks 1463 | 1464 | .. code-block:: shell 1465 | 1466 | tox 1467 | 1468 | Alternatively, you can always run a single test. See documentation below. 1469 | 1470 | Unit tests 1471 | """""""""" 1472 | 1473 | Responses uses `Pytest `_ for 1474 | testing. You can run all tests by: 1475 | 1476 | .. code-block:: shell 1477 | 1478 | tox -e py37 1479 | tox -e py310 1480 | 1481 | OR manually activate required version of Python and run 1482 | 1483 | .. code-block:: shell 1484 | 1485 | pytest 1486 | 1487 | And run a single test by: 1488 | 1489 | .. code-block:: shell 1490 | 1491 | pytest -k '' 1492 | 1493 | Type Validation 1494 | """"""""""""""" 1495 | 1496 | To verify ``type`` compliance, run `mypy `_ linter: 1497 | 1498 | .. code-block:: shell 1499 | 1500 | tox -e mypy 1501 | 1502 | OR 1503 | 1504 | .. code-block:: shell 1505 | 1506 | mypy --config-file=./mypy.ini -p responses 1507 | 1508 | Code Quality and Style 1509 | """""""""""""""""""""" 1510 | 1511 | To check code style and reformat it run: 1512 | 1513 | .. code-block:: shell 1514 | 1515 | tox -e precom 1516 | 1517 | OR 1518 | 1519 | .. code-block:: shell 1520 | 1521 | pre-commit run --all-files 1522 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | show_column_numbers=True 3 | show_error_codes = True 4 | 5 | disallow_any_unimported=False 6 | disallow_any_expr=False 7 | disallow_any_decorated=True 8 | disallow_any_explicit=False 9 | disallow_any_generics=True 10 | disallow_subclassing_any=True 11 | 12 | disallow_untyped_calls=True 13 | disallow_untyped_defs=True 14 | disallow_incomplete_defs=True 15 | check_untyped_defs=True 16 | disallow_untyped_decorators=True 17 | 18 | no_implicit_optional=True 19 | strict_optional=True 20 | 21 | warn_redundant_casts=True 22 | warn_unused_ignores=True 23 | warn_no_return=True 24 | warn_return_any=False 25 | warn_unreachable=False 26 | 27 | strict_equality=True 28 | ignore_missing_imports=True 29 | 30 | [mypy-responses.tests.*] 31 | disallow_untyped_calls=False 32 | disallow_untyped_defs=False 33 | disable_error_code = union-attr 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # Suggest a reasonably modern floor for setuptools to ensure 3 | # the source dist package is assembled with all the expected resources. 4 | requires = ["setuptools >= 60", "wheel"] 5 | build-backend = "setuptools.build_meta" 6 | -------------------------------------------------------------------------------- /responses/__init__.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json as json_module 3 | import logging 4 | from functools import partialmethod 5 | from functools import wraps 6 | from http import client 7 | from itertools import groupby 8 | from re import Pattern 9 | from threading import Lock as _ThreadingLock 10 | from typing import TYPE_CHECKING 11 | from typing import Any 12 | from typing import Callable 13 | from typing import Dict 14 | from typing import Iterable 15 | from typing import Iterator 16 | from typing import List 17 | from typing import Mapping 18 | from typing import NamedTuple 19 | from typing import Optional 20 | from typing import Sequence 21 | from typing import Sized 22 | from typing import Tuple 23 | from typing import Type 24 | from typing import Union 25 | from typing import overload 26 | from warnings import warn 27 | 28 | import yaml 29 | from requests.adapters import HTTPAdapter 30 | from requests.adapters import MaxRetryError 31 | from requests.exceptions import ConnectionError 32 | from requests.exceptions import RetryError 33 | 34 | from responses.matchers import json_params_matcher as _json_params_matcher 35 | from responses.matchers import query_string_matcher as _query_string_matcher 36 | from responses.matchers import urlencoded_params_matcher as _urlencoded_params_matcher 37 | from responses.registries import FirstMatchRegistry 38 | 39 | try: 40 | from typing_extensions import Literal 41 | except ImportError: # pragma: no cover 42 | from typing import Literal # type: ignore # pragma: no cover 43 | 44 | from io import BufferedReader 45 | from io import BytesIO 46 | from unittest import mock as std_mock 47 | from urllib.parse import parse_qsl 48 | from urllib.parse import quote 49 | from urllib.parse import urlsplit 50 | from urllib.parse import urlunparse 51 | from urllib.parse import urlunsplit 52 | 53 | from urllib3.response import HTTPHeaderDict 54 | from urllib3.response import HTTPResponse 55 | from urllib3.util.url import parse_url 56 | 57 | if TYPE_CHECKING: # pragma: no cover 58 | # import only for linter run 59 | import os 60 | from typing import Protocol 61 | from unittest.mock import _patch as _mock_patcher 62 | 63 | from requests import PreparedRequest 64 | from requests import models 65 | from urllib3 import Retry as _Retry 66 | 67 | class UnboundSend(Protocol): 68 | def __call__( 69 | self, 70 | adapter: HTTPAdapter, 71 | request: PreparedRequest, 72 | *args: Any, 73 | **kwargs: Any, 74 | ) -> models.Response: 75 | ... 76 | 77 | # Block of type annotations 78 | _Body = Union[str, BaseException, "Response", BufferedReader, bytes, None] 79 | _F = Callable[..., Any] 80 | _HeaderSet = Optional[Union[Mapping[str, str], List[Tuple[str, str]]]] 81 | _MatcherIterable = Iterable[Callable[..., Tuple[bool, str]]] 82 | _HTTPMethodOrResponse = Optional[Union[str, "BaseResponse"]] 83 | _URLPatternType = Union["Pattern[str]", str] 84 | _HTTPAdapterSend = Callable[ 85 | [ 86 | HTTPAdapter, 87 | PreparedRequest, 88 | bool, 89 | Union[float, Tuple[float, float], Tuple[float, None], None], 90 | Union[bool, str], 91 | Union[bytes, str, Tuple[Union[bytes, str], Union[bytes, str]], None], 92 | Optional[Mapping[str, str]], 93 | ], 94 | models.Response, 95 | ] 96 | 97 | 98 | class Call(NamedTuple): 99 | request: "PreparedRequest" 100 | response: "_Body" 101 | 102 | 103 | _real_send = HTTPAdapter.send 104 | _UNSET = object() 105 | 106 | logger = logging.getLogger("responses") 107 | 108 | 109 | class FalseBool: 110 | """Class to mock up built-in False boolean. 111 | 112 | Used for backwards compatibility, see 113 | https://github.com/getsentry/responses/issues/464 114 | """ 115 | 116 | def __bool__(self) -> bool: 117 | return False 118 | 119 | 120 | def urlencoded_params_matcher(params: Optional[Dict[str, str]]) -> Callable[..., Any]: 121 | warn( 122 | "Function is deprecated. Use 'from responses.matchers import urlencoded_params_matcher'", 123 | DeprecationWarning, 124 | ) 125 | return _urlencoded_params_matcher(params) 126 | 127 | 128 | def json_params_matcher(params: Optional[Dict[str, Any]]) -> Callable[..., Any]: 129 | warn( 130 | "Function is deprecated. Use 'from responses.matchers import json_params_matcher'", 131 | DeprecationWarning, 132 | ) 133 | return _json_params_matcher(params) 134 | 135 | 136 | def _has_unicode(s: str) -> bool: 137 | return any(ord(char) > 128 for char in s) 138 | 139 | 140 | def _clean_unicode(url: str) -> str: 141 | """Clean up URLs, which use punycode to handle unicode chars. 142 | 143 | Applies percent encoding to URL path and query if required. 144 | 145 | Parameters 146 | ---------- 147 | url : str 148 | URL that should be cleaned from unicode 149 | 150 | Returns 151 | ------- 152 | str 153 | Cleaned URL 154 | 155 | """ 156 | urllist = list(urlsplit(url)) 157 | netloc = urllist[1] 158 | if _has_unicode(netloc): 159 | domains = netloc.split(".") 160 | for i, d in enumerate(domains): 161 | if _has_unicode(d): 162 | d = "xn--" + d.encode("punycode").decode("ascii") 163 | domains[i] = d 164 | urllist[1] = ".".join(domains) 165 | url = urlunsplit(urllist) 166 | 167 | # Clean up path/query/params, which use url-encoding to handle unicode chars 168 | chars = list(url) 169 | for i, x in enumerate(chars): 170 | if ord(x) > 128: 171 | chars[i] = quote(x) 172 | 173 | return "".join(chars) 174 | 175 | 176 | def get_wrapped( 177 | func: Callable[..., Any], 178 | responses: "RequestsMock", 179 | *, 180 | registry: Optional[Any] = None, 181 | assert_all_requests_are_fired: Optional[bool] = None, 182 | ) -> Callable[..., Any]: 183 | """Wrap provided function inside ``responses`` context manager. 184 | 185 | Provides a synchronous or asynchronous wrapper for the function. 186 | 187 | 188 | Parameters 189 | ---------- 190 | func : Callable 191 | Function to wrap. 192 | responses : RequestsMock 193 | Mock object that is used as context manager. 194 | registry : FirstMatchRegistry, optional 195 | Custom registry that should be applied. See ``responses.registries`` 196 | assert_all_requests_are_fired : bool 197 | Raise an error if not all registered responses were executed. 198 | 199 | Returns 200 | ------- 201 | Callable 202 | Wrapped function 203 | 204 | """ 205 | assert_mock = std_mock.patch.object( 206 | target=responses, 207 | attribute="assert_all_requests_are_fired", 208 | new=assert_all_requests_are_fired, 209 | ) 210 | 211 | if inspect.iscoroutinefunction(func): 212 | # set asynchronous wrapper if requestor function is asynchronous 213 | @wraps(func) 214 | async def wrapper(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] 215 | if registry is not None: 216 | responses._set_registry(registry) 217 | 218 | with assert_mock, responses: 219 | return await func(*args, **kwargs) 220 | 221 | else: 222 | 223 | @wraps(func) 224 | def wrapper(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] 225 | if registry is not None: 226 | responses._set_registry(registry) 227 | 228 | with assert_mock, responses: 229 | # set 'assert_all_requests_are_fired' temporarily for a single run. 230 | # Mock automatically unsets to avoid leakage to another decorated 231 | # function since we still apply the value on 'responses.mock' object 232 | return func(*args, **kwargs) 233 | 234 | return wrapper 235 | 236 | 237 | class CallList(Sequence[Any], Sized): 238 | def __init__(self) -> None: 239 | self._calls: List[Call] = [] 240 | 241 | def __iter__(self) -> Iterator[Call]: 242 | return iter(self._calls) 243 | 244 | def __len__(self) -> int: 245 | return len(self._calls) 246 | 247 | @overload 248 | def __getitem__(self, idx: int) -> Call: 249 | """Overload for scenario when index is provided.""" 250 | 251 | @overload 252 | def __getitem__(self, idx: "slice[int, int, Optional[int]]") -> List[Call]: 253 | """Overload for scenario when slice is provided.""" 254 | 255 | def __getitem__(self, idx: Union[int, slice]) -> Union[Call, List[Call]]: 256 | return self._calls[idx] 257 | 258 | def add(self, request: "PreparedRequest", response: "_Body") -> None: 259 | self._calls.append(Call(request, response)) 260 | 261 | def add_call(self, call: Call) -> None: 262 | self._calls.append(call) 263 | 264 | def reset(self) -> None: 265 | self._calls = [] 266 | 267 | 268 | def _ensure_url_default_path( 269 | url: "_URLPatternType", 270 | ) -> "_URLPatternType": 271 | """Add empty URL path '/' if doesn't exist. 272 | 273 | Examples 274 | -------- 275 | >>> _ensure_url_default_path("http://example.com") 276 | "http://example.com/" 277 | 278 | Parameters 279 | ---------- 280 | url : str or re.Pattern 281 | URL to validate. 282 | 283 | Returns 284 | ------- 285 | url : str or re.Pattern 286 | Modified URL if str or unchanged re.Pattern 287 | 288 | """ 289 | if isinstance(url, str): 290 | url_parts = list(urlsplit(url)) 291 | if url_parts[2] == "": 292 | url_parts[2] = "/" 293 | url = urlunsplit(url_parts) 294 | return url 295 | 296 | 297 | def _get_url_and_path(url: str) -> str: 298 | """Construct URL only containing scheme, netloc and path by truncating other parts. 299 | 300 | This method complies with RFC 3986. 301 | 302 | Examples 303 | -------- 304 | >>> _get_url_and_path("http://example.com/path;segment?ab=xy&zed=qwe#test=1&foo=bar") 305 | "http://example.com/path;segment" 306 | 307 | 308 | Parameters 309 | ---------- 310 | url : str 311 | URL to parse. 312 | 313 | Returns 314 | ------- 315 | url : str 316 | URL with scheme, netloc and path 317 | 318 | """ 319 | url_parsed = urlsplit(url) 320 | url_and_path = urlunparse( 321 | [url_parsed.scheme, url_parsed.netloc, url_parsed.path, None, None, None] 322 | ) 323 | return parse_url(url_and_path).url 324 | 325 | 326 | def _handle_body( 327 | body: Optional[Union[bytes, BufferedReader, str]] 328 | ) -> Union[BufferedReader, BytesIO]: 329 | """Generates `Response` body. 330 | 331 | Parameters 332 | ---------- 333 | body : str or bytes or BufferedReader 334 | Input data to generate `Response` body. 335 | 336 | Returns 337 | ------- 338 | body : BufferedReader or BytesIO 339 | `Response` body 340 | 341 | """ 342 | if isinstance(body, str): 343 | body = body.encode("utf-8") 344 | if isinstance(body, BufferedReader): 345 | return body 346 | 347 | data = BytesIO(body) # type: ignore[arg-type] 348 | 349 | def is_closed() -> bool: 350 | """ 351 | Real Response uses HTTPResponse as body object. 352 | Thus, when method is_closed is called first to check if there is any more 353 | content to consume and the file-like object is still opened 354 | 355 | This method ensures stability to work for both: 356 | https://github.com/getsentry/responses/issues/438 357 | https://github.com/getsentry/responses/issues/394 358 | 359 | where file should be intentionally be left opened to continue consumption 360 | """ 361 | if not data.closed and data.read(1): 362 | # if there is more bytes to read then keep open, but return pointer 363 | data.seek(-1, 1) 364 | return False 365 | else: 366 | if not data.closed: 367 | # close but return False to mock like is still opened 368 | data.close() 369 | return False 370 | 371 | # only if file really closed (by us) return True 372 | return True 373 | 374 | data.isclosed = is_closed # type: ignore[attr-defined] 375 | return data 376 | 377 | 378 | class BaseResponse: 379 | passthrough: bool = False 380 | content_type: Optional[str] = None 381 | headers: Optional[Mapping[str, str]] = None 382 | stream: Optional[bool] = False 383 | 384 | def __init__( 385 | self, 386 | method: str, 387 | url: "_URLPatternType", 388 | match_querystring: Union[bool, object] = None, 389 | match: "_MatcherIterable" = (), 390 | *, 391 | passthrough: bool = False, 392 | ) -> None: 393 | self.method: str = method 394 | # ensure the url has a default path set if the url is a string 395 | self.url: "_URLPatternType" = _ensure_url_default_path(url) 396 | 397 | if self._should_match_querystring(match_querystring): 398 | match = tuple(match) + ( 399 | _query_string_matcher(urlsplit(self.url).query), # type: ignore[arg-type] 400 | ) 401 | 402 | self.match: "_MatcherIterable" = match 403 | self._calls: CallList = CallList() 404 | self.passthrough = passthrough 405 | 406 | self.status: int = 200 407 | self.body: "_Body" = "" 408 | 409 | def __eq__(self, other: Any) -> bool: 410 | if not isinstance(other, BaseResponse): 411 | return False 412 | 413 | if self.method != other.method: 414 | return False 415 | 416 | # Can't simply do an equality check on the objects directly here since __eq__ isn't 417 | # implemented for regex. It might seem to work as regex is using a cache to return 418 | # the same regex instances, but it doesn't in all cases. 419 | self_url = self.url.pattern if isinstance(self.url, Pattern) else self.url 420 | other_url = other.url.pattern if isinstance(other.url, Pattern) else other.url 421 | 422 | return self_url == other_url 423 | 424 | def __ne__(self, other: Any) -> bool: 425 | return not self.__eq__(other) 426 | 427 | def _should_match_querystring( 428 | self, match_querystring_argument: Union[bool, object] 429 | ) -> Union[bool, object]: 430 | if isinstance(self.url, Pattern): 431 | # the old default from <= 0.9.0 432 | return False 433 | 434 | if match_querystring_argument is not None: 435 | if not isinstance(match_querystring_argument, FalseBool): 436 | warn( 437 | ( 438 | "Argument 'match_querystring' is deprecated. " 439 | "Use 'responses.matchers.query_param_matcher' or " 440 | "'responses.matchers.query_string_matcher'" 441 | ), 442 | DeprecationWarning, 443 | ) 444 | return match_querystring_argument 445 | 446 | return bool(urlsplit(self.url).query) 447 | 448 | def _url_matches(self, url: "_URLPatternType", other: str) -> bool: 449 | """Compares two URLs. 450 | 451 | Compares only scheme, netloc and path. If 'url' is a re.Pattern, then checks that 452 | 'other' matches the pattern. 453 | 454 | Parameters 455 | ---------- 456 | url : Union["Pattern[str]", str] 457 | Reference URL or Pattern to compare. 458 | 459 | other : str 460 | URl that should be compared. 461 | 462 | Returns 463 | ------- 464 | bool 465 | True, if URLs are identical or 'other' matches the pattern. 466 | 467 | """ 468 | if isinstance(url, str): 469 | if _has_unicode(url): 470 | url = _clean_unicode(url) 471 | 472 | return _get_url_and_path(url) == _get_url_and_path(other) 473 | 474 | elif isinstance(url, Pattern) and url.match(other): 475 | return True 476 | 477 | else: 478 | return False 479 | 480 | @staticmethod 481 | def _req_attr_matches( 482 | match: "_MatcherIterable", request: "PreparedRequest" 483 | ) -> Tuple[bool, str]: 484 | for matcher in match: 485 | valid, reason = matcher(request) 486 | if not valid: 487 | return False, reason 488 | 489 | return True, "" 490 | 491 | def get_headers(self) -> HTTPHeaderDict: 492 | headers = HTTPHeaderDict() # Duplicate headers are legal 493 | 494 | # Add Content-Type if it exists and is not already in headers 495 | if self.content_type and ( 496 | not self.headers or "Content-Type" not in self.headers 497 | ): 498 | headers["Content-Type"] = self.content_type 499 | 500 | # Extend headers if they exist 501 | if self.headers: 502 | headers.extend(self.headers) 503 | 504 | return headers 505 | 506 | def get_response(self, request: "PreparedRequest") -> HTTPResponse: 507 | raise NotImplementedError 508 | 509 | def matches(self, request: "PreparedRequest") -> Tuple[bool, str]: 510 | if request.method != self.method: 511 | return False, "Method does not match" 512 | 513 | if not self._url_matches(self.url, str(request.url)): 514 | return False, "URL does not match" 515 | 516 | valid, reason = self._req_attr_matches(self.match, request) 517 | if not valid: 518 | return False, reason 519 | 520 | return True, "" 521 | 522 | @property 523 | def call_count(self) -> int: 524 | return len(self._calls) 525 | 526 | @property 527 | def calls(self) -> CallList: 528 | return self._calls 529 | 530 | 531 | def _form_response( 532 | body: Union[BufferedReader, BytesIO], 533 | headers: Optional[Mapping[str, str]], 534 | status: int, 535 | request_method: Optional[str], 536 | ) -> HTTPResponse: 537 | """ 538 | Function to generate `urllib3.response.HTTPResponse` object. 539 | 540 | The cookie handling functionality of the `requests` library relies on the response object 541 | having an original response object with the headers stored in the `msg` attribute. 542 | Instead of supplying a file-like object of type `HTTPMessage` for the headers, we provide 543 | the headers directly. This approach eliminates the need to parse the headers into a file-like 544 | object and then rely on the library to unparse it back. These additional conversions can 545 | introduce potential errors. 546 | """ 547 | 548 | data = BytesIO() 549 | data.close() 550 | 551 | """ 552 | The type `urllib3.response.HTTPResponse` is incorrect; we should 553 | use `http.client.HTTPResponse` instead. However, changing this requires opening 554 | a real socket to imitate the object. This may not be desired, as some users may 555 | want to completely restrict network access in their tests. 556 | See https://github.com/getsentry/responses/issues/691 557 | """ 558 | orig_response = HTTPResponse( 559 | body=data, # required to avoid "ValueError: Unable to determine whether fp is closed." 560 | msg=headers, # type: ignore[arg-type] 561 | preload_content=False, 562 | ) 563 | return HTTPResponse( 564 | status=status, 565 | reason=client.responses.get(status, None), 566 | body=body, 567 | headers=headers, 568 | original_response=orig_response, # type: ignore[arg-type] # See comment above 569 | preload_content=False, 570 | request_method=request_method, 571 | ) 572 | 573 | 574 | class Response(BaseResponse): 575 | def __init__( 576 | self, 577 | method: str, 578 | url: "_URLPatternType", 579 | body: "_Body" = "", 580 | json: Optional[Any] = None, 581 | status: int = 200, 582 | headers: Optional[Mapping[str, str]] = None, 583 | stream: Optional[bool] = None, 584 | content_type: Union[str, object] = _UNSET, 585 | auto_calculate_content_length: bool = False, 586 | **kwargs: Any, 587 | ) -> None: 588 | super().__init__(method, url, **kwargs) 589 | 590 | # if we were passed a `json` argument, 591 | # override the body and content_type 592 | if json is not None: 593 | assert not body 594 | body = json_module.dumps(json) 595 | if content_type is _UNSET: 596 | content_type = "application/json" 597 | 598 | if content_type is _UNSET: 599 | if isinstance(body, str) and _has_unicode(body): 600 | content_type = "text/plain; charset=utf-8" 601 | else: 602 | content_type = "text/plain" 603 | 604 | self.body: "_Body" = body 605 | self.status: int = status 606 | self.headers: Optional[Mapping[str, str]] = headers 607 | 608 | if stream is not None: 609 | warn( 610 | "stream argument is deprecated. Use stream parameter in request directly", 611 | DeprecationWarning, 612 | ) 613 | 614 | self.stream: Optional[bool] = stream 615 | self.content_type: str = content_type # type: ignore[assignment] 616 | self.auto_calculate_content_length: bool = auto_calculate_content_length 617 | 618 | def get_response(self, request: "PreparedRequest") -> HTTPResponse: 619 | if self.body and isinstance(self.body, Exception): 620 | setattr(self.body, "request", request) 621 | raise self.body 622 | 623 | headers = self.get_headers() 624 | status = self.status 625 | 626 | assert not isinstance(self.body, (Response, BaseException)) 627 | body = _handle_body(self.body) 628 | 629 | if ( 630 | self.auto_calculate_content_length 631 | and isinstance(body, BytesIO) 632 | and "Content-Length" not in headers 633 | ): 634 | content_length = len(body.getvalue()) 635 | headers["Content-Length"] = str(content_length) 636 | 637 | return _form_response(body, headers, status, request.method) 638 | 639 | def __repr__(self) -> str: 640 | return ( 641 | "".format( 643 | url=self.url, 644 | status=self.status, 645 | content_type=self.content_type, 646 | headers=json_module.dumps(self.headers), 647 | ) 648 | ) 649 | 650 | 651 | class CallbackResponse(BaseResponse): 652 | def __init__( 653 | self, 654 | method: str, 655 | url: "_URLPatternType", 656 | callback: Callable[[Any], Any], 657 | stream: Optional[bool] = None, 658 | content_type: Optional[str] = "text/plain", 659 | **kwargs: Any, 660 | ) -> None: 661 | super().__init__(method, url, **kwargs) 662 | 663 | self.callback = callback 664 | 665 | if stream is not None: 666 | warn( 667 | "stream argument is deprecated. Use stream parameter in request directly", 668 | DeprecationWarning, 669 | ) 670 | self.stream: Optional[bool] = stream 671 | self.content_type: Optional[str] = content_type 672 | 673 | def get_response(self, request: "PreparedRequest") -> HTTPResponse: 674 | headers = self.get_headers() 675 | 676 | result = self.callback(request) 677 | if isinstance(result, Exception): 678 | raise result 679 | 680 | status, r_headers, body = result 681 | if isinstance(body, Exception): 682 | raise body 683 | 684 | # If the callback set a content-type remove the one 685 | # set in add_callback() so that we don't have multiple 686 | # content type values. 687 | has_content_type = False 688 | if isinstance(r_headers, dict) and "Content-Type" in r_headers: 689 | has_content_type = True 690 | elif isinstance(r_headers, list): 691 | has_content_type = any( 692 | [h for h in r_headers if h and h[0].lower() == "content-type"] 693 | ) 694 | if has_content_type: 695 | headers.pop("Content-Type", None) 696 | 697 | body = _handle_body(body) 698 | headers.extend(r_headers) 699 | 700 | return _form_response(body, headers, status, request.method) 701 | 702 | 703 | class PassthroughResponse(BaseResponse): 704 | def __init__(self, *args: Any, **kwargs: Any): 705 | super().__init__(*args, passthrough=True, **kwargs) 706 | 707 | 708 | class RequestsMock: 709 | DELETE: Literal["DELETE"] = "DELETE" 710 | GET: Literal["GET"] = "GET" 711 | HEAD: Literal["HEAD"] = "HEAD" 712 | OPTIONS: Literal["OPTIONS"] = "OPTIONS" 713 | PATCH: Literal["PATCH"] = "PATCH" 714 | POST: Literal["POST"] = "POST" 715 | PUT: Literal["PUT"] = "PUT" 716 | 717 | Response: Type[Response] = Response 718 | 719 | # Make the `matchers` name available under a RequestsMock instance 720 | from responses import matchers 721 | 722 | response_callback: Optional[Callable[[Any], Any]] = None 723 | 724 | def __init__( 725 | self, 726 | assert_all_requests_are_fired: bool = True, 727 | response_callback: Optional[Callable[[Any], Any]] = None, 728 | passthru_prefixes: Tuple[str, ...] = (), 729 | target: str = "requests.adapters.HTTPAdapter.send", 730 | registry: Type[FirstMatchRegistry] = FirstMatchRegistry, 731 | *, 732 | real_adapter_send: "_HTTPAdapterSend" = _real_send, 733 | ) -> None: 734 | self._calls: CallList = CallList() 735 | self.reset() 736 | self._registry: FirstMatchRegistry = registry() # call only after reset 737 | self.assert_all_requests_are_fired: bool = assert_all_requests_are_fired 738 | self.response_callback: Optional[Callable[[Any], Response]] = response_callback 739 | self.passthru_prefixes: Tuple[_URLPatternType, ...] = tuple(passthru_prefixes) 740 | self.target: str = target 741 | self._patcher: Optional["_mock_patcher[Any]"] = None 742 | self._thread_lock = _ThreadingLock() 743 | self._real_send = real_adapter_send 744 | 745 | def get_registry(self) -> FirstMatchRegistry: 746 | """Returns current registry instance with responses. 747 | 748 | Returns 749 | ------- 750 | FirstMatchRegistry 751 | Current registry instance with responses. 752 | 753 | """ 754 | return self._registry 755 | 756 | def _set_registry(self, new_registry: Type[FirstMatchRegistry]) -> None: 757 | """Replaces current registry with `new_registry`. 758 | 759 | Parameters 760 | ---------- 761 | new_registry : Type[FirstMatchRegistry] 762 | Class reference of the registry that should be set, eg OrderedRegistry 763 | 764 | """ 765 | if self.registered(): 766 | err_msg = ( 767 | "Cannot replace Registry, current registry has responses.\n" 768 | "Run 'responses.registry.reset()' first" 769 | ) 770 | raise AttributeError(err_msg) 771 | 772 | self._registry = new_registry() 773 | 774 | def reset(self) -> None: 775 | """Resets registry (including type), calls, passthru_prefixes to default values.""" 776 | self._registry = FirstMatchRegistry() 777 | self._calls.reset() 778 | self.passthru_prefixes = () 779 | 780 | def add( 781 | self, 782 | method: "_HTTPMethodOrResponse" = None, 783 | url: "Optional[_URLPatternType]" = None, 784 | body: "_Body" = "", 785 | adding_headers: "_HeaderSet" = None, 786 | *args: Any, 787 | **kwargs: Any, 788 | ) -> BaseResponse: 789 | """ 790 | >>> import responses 791 | 792 | A basic request: 793 | >>> responses.add(responses.GET, 'http://example.com') 794 | 795 | You can also directly pass an object which implements the 796 | ``BaseResponse`` interface: 797 | 798 | >>> responses.add(Response(...)) 799 | 800 | A JSON payload: 801 | 802 | >>> responses.add( 803 | >>> method='GET', 804 | >>> url='http://example.com', 805 | >>> json={'foo': 'bar'}, 806 | >>> ) 807 | 808 | Custom headers: 809 | 810 | >>> responses.add( 811 | >>> method='GET', 812 | >>> url='http://example.com', 813 | >>> headers={'X-Header': 'foo'}, 814 | >>> ) 815 | 816 | """ 817 | if isinstance(method, BaseResponse): 818 | return self._registry.add(method) 819 | 820 | if adding_headers is not None: 821 | kwargs.setdefault("headers", adding_headers) 822 | if ( 823 | "content_type" in kwargs 824 | and "headers" in kwargs 825 | and kwargs["headers"] is not None 826 | ): 827 | header_keys = [header.lower() for header in kwargs["headers"]] 828 | if "content-type" in header_keys: 829 | raise RuntimeError( 830 | "You cannot define both `content_type` and `headers[Content-Type]`." 831 | " Using the `content_type` kwarg is recommended." 832 | ) 833 | 834 | assert url is not None 835 | assert isinstance(method, str) 836 | response = Response(method=method, url=url, body=body, **kwargs) 837 | return self._registry.add(response) 838 | 839 | delete = partialmethod(add, DELETE) 840 | get = partialmethod(add, GET) 841 | head = partialmethod(add, HEAD) 842 | options = partialmethod(add, OPTIONS) 843 | patch = partialmethod(add, PATCH) 844 | post = partialmethod(add, POST) 845 | put = partialmethod(add, PUT) 846 | 847 | def _parse_response_file( 848 | self, file_path: "Union[str, bytes, os.PathLike[Any]]" 849 | ) -> "Dict[str, Any]": 850 | with open(file_path) as file: 851 | data = yaml.safe_load(file) 852 | return data 853 | 854 | def _add_from_file(self, file_path: "Union[str, bytes, os.PathLike[Any]]") -> None: 855 | data = self._parse_response_file(file_path) 856 | 857 | for rsp in data["responses"]: 858 | rsp = rsp["response"] 859 | self.add( 860 | method=rsp["method"], 861 | url=rsp["url"], 862 | body=rsp["body"], 863 | status=rsp["status"], 864 | headers=rsp["headers"] if "headers" in rsp else None, 865 | content_type=rsp["content_type"], 866 | auto_calculate_content_length=rsp["auto_calculate_content_length"], 867 | ) 868 | 869 | def add_passthru(self, prefix: "_URLPatternType") -> None: 870 | """ 871 | Register a URL prefix or regex to passthru any non-matching mock requests to. 872 | 873 | For example, to allow any request to 'https://example.com', but require 874 | mocks for the remainder, you would add the prefix as so: 875 | 876 | >>> import responses 877 | >>> responses.add_passthru('https://example.com') 878 | 879 | Regex can be used like: 880 | 881 | >>> import re 882 | >>> responses.add_passthru(re.compile('https://example.com/\\w+')) 883 | """ 884 | if not isinstance(prefix, Pattern) and _has_unicode(prefix): 885 | prefix = _clean_unicode(prefix) 886 | self.passthru_prefixes += (prefix,) 887 | 888 | def remove( 889 | self, 890 | method_or_response: "_HTTPMethodOrResponse" = None, 891 | url: "Optional[_URLPatternType]" = None, 892 | ) -> List[BaseResponse]: 893 | """ 894 | Removes a response previously added using ``add()``, identified 895 | either by a response object inheriting ``BaseResponse`` or 896 | ``method`` and ``url``. Removes all matching responses. 897 | 898 | >>> import responses 899 | >>> responses.add(responses.GET, 'http://example.org') 900 | >>> responses.remove(responses.GET, 'http://example.org') 901 | """ 902 | if isinstance(method_or_response, BaseResponse): 903 | response = method_or_response 904 | else: 905 | assert url is not None 906 | assert isinstance(method_or_response, str) 907 | response = BaseResponse(method=method_or_response, url=url) 908 | 909 | return self._registry.remove(response) 910 | 911 | def replace( 912 | self, 913 | method_or_response: "_HTTPMethodOrResponse" = None, 914 | url: "Optional[_URLPatternType]" = None, 915 | body: "_Body" = "", 916 | *args: Any, 917 | **kwargs: Any, 918 | ) -> BaseResponse: 919 | """ 920 | Replaces a response previously added using ``add()``. The signature 921 | is identical to ``add()``. The response is identified using ``method`` 922 | and ``url``, and the first matching response is replaced. 923 | 924 | >>> import responses 925 | >>> responses.add(responses.GET, 'http://example.org', json={'data': 1}) 926 | >>> responses.replace(responses.GET, 'http://example.org', json={'data': 2}) 927 | """ 928 | if isinstance(method_or_response, BaseResponse): 929 | response = method_or_response 930 | else: 931 | assert url is not None 932 | assert isinstance(method_or_response, str) 933 | response = Response(method=method_or_response, url=url, body=body, **kwargs) 934 | 935 | return self._registry.replace(response) 936 | 937 | def upsert( 938 | self, 939 | method_or_response: "_HTTPMethodOrResponse" = None, 940 | url: "Optional[_URLPatternType]" = None, 941 | body: "_Body" = "", 942 | *args: Any, 943 | **kwargs: Any, 944 | ) -> BaseResponse: 945 | """ 946 | Replaces a response previously added using ``add()``, or adds the response 947 | if no response exists. Responses are matched using ``method``and ``url``. 948 | The first matching response is replaced. 949 | 950 | >>> import responses 951 | >>> responses.add(responses.GET, 'http://example.org', json={'data': 1}) 952 | >>> responses.upsert(responses.GET, 'http://example.org', json={'data': 2}) 953 | """ 954 | try: 955 | return self.replace(method_or_response, url, body, *args, **kwargs) 956 | except ValueError: 957 | return self.add(method_or_response, url, body, *args, **kwargs) 958 | 959 | def add_callback( 960 | self, 961 | method: str, 962 | url: "_URLPatternType", 963 | callback: Callable[ 964 | ["PreparedRequest"], 965 | Union[Exception, Tuple[int, Mapping[str, str], "_Body"]], 966 | ], 967 | match_querystring: Union[bool, FalseBool] = FalseBool(), 968 | content_type: Optional[str] = "text/plain", 969 | match: "_MatcherIterable" = (), 970 | ) -> None: 971 | self._registry.add( 972 | CallbackResponse( 973 | url=url, 974 | method=method, 975 | callback=callback, 976 | content_type=content_type, 977 | match_querystring=match_querystring, 978 | match=match, 979 | ) 980 | ) 981 | 982 | def registered(self) -> List["BaseResponse"]: 983 | return self._registry.registered 984 | 985 | @property 986 | def calls(self) -> CallList: 987 | return self._calls 988 | 989 | def __enter__(self) -> "RequestsMock": 990 | self.start() 991 | return self 992 | 993 | def __exit__(self, type: Any, value: Any, traceback: Any) -> bool: 994 | success = type is None 995 | try: 996 | self.stop(allow_assert=success) 997 | finally: 998 | self.reset() 999 | return success 1000 | 1001 | @overload 1002 | def activate(self, func: "_F" = ...) -> "_F": 1003 | """Overload for scenario when 'responses.activate' is used.""" 1004 | 1005 | @overload 1006 | def activate( # type: ignore[misc] 1007 | self, 1008 | *, 1009 | registry: Type[Any] = ..., 1010 | assert_all_requests_are_fired: bool = ..., 1011 | ) -> Callable[["_F"], "_F"]: 1012 | """Overload for scenario when 1013 | 'responses.activate(registry=, assert_all_requests_are_fired=True)' is used. 1014 | See https://github.com/getsentry/responses/pull/469 for more details 1015 | """ 1016 | 1017 | def activate( 1018 | self, 1019 | func: Optional["_F"] = None, 1020 | *, 1021 | registry: Optional[Type[Any]] = None, 1022 | assert_all_requests_are_fired: bool = False, 1023 | ) -> Union[Callable[["_F"], "_F"], "_F"]: 1024 | if func is not None: 1025 | return get_wrapped(func, self) 1026 | 1027 | def deco_activate(function: "_F") -> Callable[..., Any]: 1028 | return get_wrapped( 1029 | function, 1030 | self, 1031 | registry=registry, 1032 | assert_all_requests_are_fired=assert_all_requests_are_fired, 1033 | ) 1034 | 1035 | return deco_activate 1036 | 1037 | def _find_match( 1038 | self, request: "PreparedRequest" 1039 | ) -> Tuple[Optional["BaseResponse"], List[str]]: 1040 | """ 1041 | Iterates through all available matches and validates if any of them matches the request 1042 | 1043 | :param request: (PreparedRequest), request object 1044 | :return: 1045 | (Response) found match. If multiple found, then remove & return the first match. 1046 | (list) list with reasons why other matches don't match 1047 | """ 1048 | with self._thread_lock: 1049 | return self._registry.find(request) 1050 | 1051 | def _parse_request_params( 1052 | self, url: str 1053 | ) -> Dict[str, Union[str, int, float, List[Optional[Union[str, int, float]]]]]: 1054 | params: Dict[str, Union[str, int, float, List[Any]]] = {} 1055 | for key, val in groupby(parse_qsl(urlsplit(url).query), lambda kv: kv[0]): 1056 | values = list(map(lambda x: x[1], val)) 1057 | if len(values) == 1: 1058 | values = values[0] # type: ignore[assignment] 1059 | params[key] = values 1060 | return params 1061 | 1062 | def _read_filelike_body( 1063 | self, body: Union[str, bytes, BufferedReader, None] 1064 | ) -> Union[str, bytes, None]: 1065 | # Requests/urllib support multiple types of body, including file-like objects. 1066 | # Read from the file if it's a file-like object to avoid storing a closed file 1067 | # in the call list and allow the user to compare against the data that was in the 1068 | # request. 1069 | # See GH #719 1070 | if isinstance(body, str) or isinstance(body, bytes) or body is None: 1071 | return body 1072 | # Based on 1073 | # https://github.com/urllib3/urllib3/blob/abbfbcb1dd274fc54b4f0a7785fd04d59b634195/src/urllib3/util/request.py#L220 1074 | if hasattr(body, "read") or isinstance(body, BufferedReader): 1075 | return body.read() 1076 | return body 1077 | 1078 | def _on_request( 1079 | self, 1080 | adapter: "HTTPAdapter", 1081 | request: "PreparedRequest", 1082 | *, 1083 | retries: Optional["_Retry"] = None, 1084 | **kwargs: Any, 1085 | ) -> "models.Response": 1086 | # add attributes params and req_kwargs to 'request' object for further match comparison 1087 | # original request object does not have these attributes 1088 | request.params = self._parse_request_params(request.path_url) # type: ignore[attr-defined] 1089 | request.req_kwargs = kwargs # type: ignore[attr-defined] 1090 | request_url = str(request.url) 1091 | request.body = self._read_filelike_body(request.body) 1092 | 1093 | match, match_failed_reasons = self._find_match(request) 1094 | resp_callback = self.response_callback 1095 | 1096 | if match is None: 1097 | if any( 1098 | [ 1099 | p.match(request_url) 1100 | if isinstance(p, Pattern) 1101 | else request_url.startswith(p) 1102 | for p in self.passthru_prefixes 1103 | ] 1104 | ): 1105 | logger.info("request.allowed-passthru", extra={"url": request_url}) 1106 | return self._real_send(adapter, request, **kwargs) # type: ignore 1107 | 1108 | error_msg = ( 1109 | "Connection refused by Responses - the call doesn't " 1110 | "match any registered mock.\n\n" 1111 | "Request: \n" 1112 | f"- {request.method} {request_url}\n\n" 1113 | "Available matches:\n" 1114 | ) 1115 | for i, m in enumerate(self.registered()): 1116 | error_msg += "- {} {} {}\n".format( 1117 | m.method, m.url, match_failed_reasons[i] 1118 | ) 1119 | 1120 | if self.passthru_prefixes: 1121 | error_msg += "Passthru prefixes:\n" 1122 | for p in self.passthru_prefixes: 1123 | error_msg += f"- {p}\n" 1124 | 1125 | response = ConnectionError(error_msg) 1126 | response.request = request 1127 | 1128 | self._calls.add(request, response) 1129 | raise response 1130 | 1131 | if match.passthrough: 1132 | logger.info("request.passthrough-response", extra={"url": request_url}) 1133 | response = self._real_send(adapter, request, **kwargs) # type: ignore 1134 | else: 1135 | try: 1136 | response = adapter.build_response( # type: ignore[assignment] 1137 | request, match.get_response(request) 1138 | ) 1139 | except BaseException as response: 1140 | call = Call(request, response) 1141 | self._calls.add_call(call) 1142 | match.calls.add_call(call) 1143 | raise 1144 | 1145 | if resp_callback: 1146 | response = resp_callback(response) # type: ignore[misc] 1147 | call = Call(request, response) # type: ignore[misc] 1148 | self._calls.add_call(call) 1149 | match.calls.add_call(call) 1150 | 1151 | retries = retries or adapter.max_retries 1152 | # first validate that current request is eligible to be retried. 1153 | # See ``urllib3.util.retry.Retry`` documentation. 1154 | if retries.is_retry( 1155 | method=response.request.method, status_code=response.status_code # type: ignore[misc] 1156 | ): 1157 | try: 1158 | retries = retries.increment( 1159 | method=response.request.method, # type: ignore[misc] 1160 | url=response.url, # type: ignore[misc] 1161 | response=response.raw, # type: ignore[misc] 1162 | ) 1163 | return self._on_request(adapter, request, retries=retries, **kwargs) 1164 | except MaxRetryError as e: 1165 | if retries.raise_on_status: 1166 | """Since we call 'retries.increment()' by ourselves, we always set "error" 1167 | argument equal to None, thus, MaxRetryError exception will be raised with 1168 | ResponseError as a 'reason'. 1169 | 1170 | Here we're emulating the `if isinstance(e.reason, ResponseError):` 1171 | branch found at: 1172 | https://github.com/psf/requests/blob/ 1173 | 177dd90f18a8f4dc79a7d2049f0a3f4fcc5932a0/requests/adapters.py#L549 1174 | """ 1175 | raise RetryError(e, request=request) 1176 | 1177 | return response 1178 | return response 1179 | 1180 | def unbound_on_send(self) -> "UnboundSend": 1181 | def send( 1182 | adapter: "HTTPAdapter", 1183 | request: "PreparedRequest", 1184 | *args: Any, 1185 | **kwargs: Any, 1186 | ) -> "models.Response": 1187 | if args: 1188 | # that probably means that the request was sent from the custom adapter 1189 | # It is fully legit to send positional args from adapter, although, 1190 | # `requests` implementation does it always with kwargs 1191 | # See for more info: https://github.com/getsentry/responses/issues/642 1192 | try: 1193 | kwargs["stream"] = args[0] 1194 | kwargs["timeout"] = args[1] 1195 | kwargs["verify"] = args[2] 1196 | kwargs["cert"] = args[3] 1197 | kwargs["proxies"] = args[4] 1198 | except IndexError: 1199 | # not all kwargs are required 1200 | pass 1201 | 1202 | return self._on_request(adapter, request, **kwargs) 1203 | 1204 | return send 1205 | 1206 | def start(self) -> None: 1207 | if self._patcher: 1208 | # we must not override value of the _patcher if already applied 1209 | # this prevents issues when one decorated function is called from 1210 | # another decorated function 1211 | return 1212 | 1213 | self._patcher = std_mock.patch(target=self.target, new=self.unbound_on_send()) 1214 | self._patcher.start() 1215 | 1216 | def stop(self, allow_assert: bool = True) -> None: 1217 | if self._patcher: 1218 | # prevent stopping unstarted patchers 1219 | self._patcher.stop() 1220 | 1221 | # once patcher is stopped, clean it. This is required to create a new 1222 | # fresh patcher on self.start() 1223 | self._patcher = None 1224 | 1225 | if not self.assert_all_requests_are_fired: 1226 | return 1227 | 1228 | if not allow_assert: 1229 | return 1230 | 1231 | not_called = [m for m in self.registered() if m.call_count == 0] 1232 | if not_called: 1233 | raise AssertionError( 1234 | "Not all requests have been executed {!r}".format( 1235 | [(match.method, match.url) for match in not_called] 1236 | ) 1237 | ) 1238 | 1239 | def assert_call_count(self, url: str, count: int) -> bool: 1240 | call_count = len( 1241 | [ 1242 | 1 1243 | for call in self.calls 1244 | if call.request.url == _ensure_url_default_path(url) 1245 | ] 1246 | ) 1247 | if call_count == count: 1248 | return True 1249 | else: 1250 | raise AssertionError( 1251 | f"Expected URL '{url}' to be called {count} times. Called {call_count} times." 1252 | ) 1253 | 1254 | 1255 | # expose default mock namespace 1256 | mock = _default_mock = RequestsMock(assert_all_requests_are_fired=False) 1257 | __all__ = [ 1258 | "CallbackResponse", 1259 | "Response", 1260 | "RequestsMock", 1261 | # Exposed by the RequestsMock class: 1262 | "activate", 1263 | "add", 1264 | "_add_from_file", 1265 | "add_callback", 1266 | "add_passthru", 1267 | "_deprecated_assert_all_requests_are_fired", 1268 | "assert_call_count", 1269 | "calls", 1270 | "delete", 1271 | "DELETE", 1272 | "get", 1273 | "GET", 1274 | "head", 1275 | "HEAD", 1276 | "options", 1277 | "OPTIONS", 1278 | "_deprecated_passthru_prefixes", 1279 | "patch", 1280 | "PATCH", 1281 | "post", 1282 | "POST", 1283 | "put", 1284 | "PUT", 1285 | "registered", 1286 | "remove", 1287 | "replace", 1288 | "reset", 1289 | "response_callback", 1290 | "start", 1291 | "stop", 1292 | "_deprecated_target", 1293 | "upsert", 1294 | ] 1295 | 1296 | # expose only methods and/or read-only methods 1297 | activate = _default_mock.activate 1298 | add = _default_mock.add 1299 | _add_from_file = _default_mock._add_from_file 1300 | add_callback = _default_mock.add_callback 1301 | add_passthru = _default_mock.add_passthru 1302 | _deprecated_assert_all_requests_are_fired = _default_mock.assert_all_requests_are_fired 1303 | assert_call_count = _default_mock.assert_call_count 1304 | calls = _default_mock.calls 1305 | delete = _default_mock.delete 1306 | DELETE = _default_mock.DELETE 1307 | get = _default_mock.get 1308 | GET = _default_mock.GET 1309 | head = _default_mock.head 1310 | HEAD = _default_mock.HEAD 1311 | options = _default_mock.options 1312 | OPTIONS = _default_mock.OPTIONS 1313 | _deprecated_passthru_prefixes = _default_mock.passthru_prefixes 1314 | patch = _default_mock.patch 1315 | PATCH = _default_mock.PATCH 1316 | post = _default_mock.post 1317 | POST = _default_mock.POST 1318 | put = _default_mock.put 1319 | PUT = _default_mock.PUT 1320 | registered = _default_mock.registered 1321 | remove = _default_mock.remove 1322 | replace = _default_mock.replace 1323 | reset = _default_mock.reset 1324 | response_callback = _default_mock.response_callback 1325 | start = _default_mock.start 1326 | stop = _default_mock.stop 1327 | _deprecated_target = _default_mock.target 1328 | upsert = _default_mock.upsert 1329 | 1330 | 1331 | deprecated_names = ["assert_all_requests_are_fired", "passthru_prefixes", "target"] 1332 | 1333 | 1334 | def __getattr__(name: str) -> Any: 1335 | if name in deprecated_names: 1336 | warn( 1337 | f"{name} is deprecated. Please use 'responses.mock.{name}", 1338 | DeprecationWarning, 1339 | ) 1340 | return globals()[f"_deprecated_{name}"] 1341 | raise AttributeError(f"module {__name__} has no attribute {name}") 1342 | -------------------------------------------------------------------------------- /responses/_recorder.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: # pragma: no cover 5 | import os 6 | 7 | from typing import Any 8 | from typing import BinaryIO 9 | from typing import Callable 10 | from typing import Dict 11 | from typing import List 12 | from typing import Optional 13 | from typing import Type 14 | from typing import Union 15 | from responses import FirstMatchRegistry 16 | from responses import HTTPAdapter 17 | from responses import PreparedRequest 18 | from responses import models 19 | from responses import _F 20 | from responses import BaseResponse 21 | 22 | from io import TextIOWrapper 23 | 24 | import yaml 25 | 26 | from responses import RequestsMock 27 | from responses import Response 28 | from responses import _real_send 29 | from responses.registries import OrderedRegistry 30 | 31 | 32 | def _remove_nones(d: "Any") -> "Any": 33 | if isinstance(d, dict): 34 | return {k: _remove_nones(v) for k, v in d.items() if v is not None} 35 | if isinstance(d, list): 36 | return [_remove_nones(i) for i in d] 37 | return d 38 | 39 | 40 | def _remove_default_headers(data: "Any") -> "Any": 41 | """ 42 | It would be too verbose to store these headers in the file generated by the 43 | record functionality. 44 | """ 45 | if isinstance(data, dict): 46 | keys_to_remove = [ 47 | "Content-Length", 48 | "Content-Type", 49 | "Date", 50 | "Server", 51 | "Connection", 52 | "Content-Encoding", 53 | ] 54 | for i, response in enumerate(data["responses"]): 55 | for key in keys_to_remove: 56 | if key in response["response"]["headers"]: 57 | del data["responses"][i]["response"]["headers"][key] 58 | if not response["response"]["headers"]: 59 | del data["responses"][i]["response"]["headers"] 60 | return data 61 | 62 | 63 | def _dump( 64 | registered: "List[BaseResponse]", 65 | destination: "Union[BinaryIO, TextIOWrapper]", 66 | dumper: "Callable[[Union[Dict[Any, Any], List[Any]], Union[BinaryIO, TextIOWrapper]], Any]", 67 | ) -> None: 68 | data: Dict[str, Any] = {"responses": []} 69 | for rsp in registered: 70 | try: 71 | content_length = rsp.auto_calculate_content_length # type: ignore[attr-defined] 72 | data["responses"].append( 73 | { 74 | "response": { 75 | "method": rsp.method, 76 | "url": rsp.url, 77 | "body": rsp.body, 78 | "status": rsp.status, 79 | "headers": rsp.headers, 80 | "content_type": rsp.content_type, 81 | "auto_calculate_content_length": content_length, 82 | } 83 | } 84 | ) 85 | except AttributeError as exc: # pragma: no cover 86 | raise AttributeError( 87 | "Cannot dump response object." 88 | "Probably you use custom Response object that is missing required attributes" 89 | ) from exc 90 | 91 | dumper(_remove_default_headers(_remove_nones(data)), destination) 92 | 93 | 94 | class Recorder(RequestsMock): 95 | def __init__( 96 | self, 97 | *, 98 | target: str = "requests.adapters.HTTPAdapter.send", 99 | registry: "Type[FirstMatchRegistry]" = OrderedRegistry, 100 | ) -> None: 101 | super().__init__(target=target, registry=registry) 102 | 103 | def reset(self) -> None: 104 | self._registry = OrderedRegistry() 105 | 106 | def record( 107 | self, *, file_path: "Union[str, bytes, os.PathLike[Any]]" = "response.yaml" 108 | ) -> "Union[Callable[[_F], _F], _F]": 109 | def deco_record(function: "_F") -> "Callable[..., Any]": 110 | @wraps(function) 111 | def wrapper(*args: "Any", **kwargs: "Any") -> "Any": # type: ignore[misc] 112 | with self: 113 | ret = function(*args, **kwargs) 114 | self.dump_to_file( 115 | file_path=file_path, registered=self.get_registry().registered 116 | ) 117 | 118 | return ret 119 | 120 | return wrapper 121 | 122 | return deco_record 123 | 124 | def dump_to_file( 125 | self, 126 | file_path: "Union[str, bytes, os.PathLike[Any]]", 127 | *, 128 | registered: "Optional[List[BaseResponse]]" = None, 129 | ) -> None: 130 | """Dump the recorded responses to a file.""" 131 | if registered is None: 132 | registered = self.get_registry().registered 133 | with open(file_path, "w") as file: 134 | _dump(registered, file, yaml.dump) 135 | 136 | def _on_request( 137 | self, 138 | adapter: "HTTPAdapter", 139 | request: "PreparedRequest", 140 | **kwargs: "Any", 141 | ) -> "models.Response": 142 | # add attributes params and req_kwargs to 'request' object for further match comparison 143 | # original request object does not have these attributes 144 | request.params = self._parse_request_params(request.path_url) # type: ignore[attr-defined] 145 | request.req_kwargs = kwargs # type: ignore[attr-defined] 146 | requests_response = _real_send(adapter, request, **kwargs) 147 | headers_values = { 148 | key: value for key, value in requests_response.headers.items() 149 | } 150 | responses_response = Response( 151 | method=str(request.method), 152 | url=str(requests_response.request.url), 153 | status=requests_response.status_code, 154 | body=requests_response.text, 155 | headers=headers_values, 156 | ) 157 | self._registry.add(responses_response) 158 | return requests_response 159 | 160 | def stop(self, allow_assert: bool = True) -> None: 161 | super().stop(allow_assert=False) 162 | 163 | 164 | recorder = Recorder() 165 | record = recorder.record 166 | -------------------------------------------------------------------------------- /responses/matchers.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import json as json_module 3 | import re 4 | from json.decoder import JSONDecodeError 5 | from typing import Any 6 | from typing import Callable 7 | from typing import List 8 | from typing import Mapping 9 | from typing import MutableMapping 10 | from typing import Optional 11 | from typing import Pattern 12 | from typing import Tuple 13 | from typing import Union 14 | from urllib.parse import parse_qsl 15 | from urllib.parse import urlparse 16 | 17 | from requests import PreparedRequest 18 | from urllib3.util.url import parse_url 19 | 20 | 21 | def _filter_dict_recursively( 22 | dict1: Mapping[Any, Any], dict2: Mapping[Any, Any] 23 | ) -> Mapping[Any, Any]: 24 | filtered_dict = {} 25 | for k, val in dict1.items(): 26 | if k in dict2: 27 | if isinstance(val, dict): 28 | val = _filter_dict_recursively(val, dict2[k]) 29 | filtered_dict[k] = val 30 | 31 | return filtered_dict 32 | 33 | 34 | def body_matcher(params: str, *, allow_blank: bool = False) -> Callable[..., Any]: 35 | def match(request: PreparedRequest) -> Tuple[bool, str]: 36 | reason = "" 37 | if isinstance(request.body, bytes): 38 | request_body = request.body.decode("utf-8") 39 | else: 40 | request_body = str(request.body) 41 | valid = True if request_body == params else False 42 | if not valid: 43 | reason = f"request.body doesn't match {params} doesn't match {request_body}" 44 | return valid, reason 45 | 46 | return match 47 | 48 | 49 | def urlencoded_params_matcher( 50 | params: Optional[Mapping[str, str]], *, allow_blank: bool = False 51 | ) -> Callable[..., Any]: 52 | """ 53 | Matches URL encoded data 54 | 55 | :param params: (dict) data provided to 'data' arg of request 56 | :return: (func) matcher 57 | """ 58 | 59 | def match(request: PreparedRequest) -> Tuple[bool, str]: 60 | reason = "" 61 | request_body = request.body 62 | qsl_body = ( 63 | dict(parse_qsl(request_body, keep_blank_values=allow_blank)) # type: ignore[type-var] 64 | if request_body 65 | else {} 66 | ) 67 | params_dict = params or {} 68 | valid = params is None if request_body is None else params_dict == qsl_body 69 | if not valid: 70 | reason = ( 71 | f"request.body doesn't match: {qsl_body} doesn't match {params_dict}" 72 | ) 73 | 74 | return valid, reason 75 | 76 | return match 77 | 78 | 79 | def json_params_matcher( 80 | params: Optional[Union[Mapping[str, Any], List[Any]]], *, strict_match: bool = True 81 | ) -> Callable[..., Any]: 82 | """Matches JSON encoded data of request body. 83 | 84 | Parameters 85 | ---------- 86 | params : dict or list 87 | JSON object provided to 'json' arg of request or a part of it if used in 88 | conjunction with ``strict_match=False``. 89 | strict_match : bool, default=True 90 | Applied only when JSON object is a dictionary. 91 | If set to ``True``, validates that all keys of JSON object match. 92 | If set to ``False``, original request may contain additional keys. 93 | 94 | 95 | Returns 96 | ------- 97 | Callable 98 | Matcher function. 99 | 100 | """ 101 | 102 | def match(request: PreparedRequest) -> Tuple[bool, str]: 103 | reason = "" 104 | request_body = request.body 105 | json_params = (params or {}) if not isinstance(params, list) else params 106 | try: 107 | if isinstance(request.body, bytes): 108 | try: 109 | request_body = request.body.decode("utf-8") 110 | except UnicodeDecodeError: 111 | request_body = gzip.decompress(request.body).decode("utf-8") 112 | json_body = json_module.loads(request_body) if request_body else {} 113 | 114 | if ( 115 | not strict_match 116 | and isinstance(json_body, dict) 117 | and isinstance(json_params, dict) 118 | ): 119 | # filter down to just the params specified in the matcher 120 | json_body = _filter_dict_recursively(json_body, json_params) 121 | 122 | valid = params is None if request_body is None else json_params == json_body 123 | 124 | if not valid: 125 | reason = f"request.body doesn't match: {json_body} doesn't match {json_params}" 126 | if not strict_match: 127 | reason += ( 128 | "\nNote: You use non-strict parameters check, " 129 | "to change it use `strict_match=True`." 130 | ) 131 | 132 | except JSONDecodeError: 133 | valid = False 134 | reason = ( 135 | "request.body doesn't match: JSONDecodeError: Cannot parse request.body" 136 | ) 137 | 138 | return valid, reason 139 | 140 | return match 141 | 142 | 143 | def fragment_identifier_matcher(identifier: Optional[str]) -> Callable[..., Any]: 144 | def match(request: PreparedRequest) -> Tuple[bool, str]: 145 | reason = "" 146 | url_fragment = urlparse(request.url).fragment 147 | if identifier: 148 | url_fragment_qsl = sorted(parse_qsl(url_fragment)) # type: ignore[type-var] 149 | identifier_qsl = sorted(parse_qsl(identifier)) 150 | valid = identifier_qsl == url_fragment_qsl 151 | else: 152 | valid = not url_fragment 153 | 154 | if not valid: 155 | reason = ( 156 | "URL fragment identifier is different: " # type: ignore[str-bytes-safe] 157 | f"{identifier} doesn't match {url_fragment}" 158 | ) 159 | 160 | return valid, reason 161 | 162 | return match 163 | 164 | 165 | def query_param_matcher( 166 | params: Optional[MutableMapping[str, Any]], *, strict_match: bool = True 167 | ) -> Callable[..., Any]: 168 | """Matcher to match 'params' argument in request. 169 | 170 | Parameters 171 | ---------- 172 | params : dict 173 | The same as provided to request or a part of it if used in 174 | conjunction with ``strict_match=False``. 175 | strict_match : bool, default=True 176 | If set to ``True``, validates that all parameters match. 177 | If set to ``False``, original request may contain additional parameters. 178 | 179 | 180 | Returns 181 | ------- 182 | Callable 183 | Matcher function. 184 | 185 | """ 186 | 187 | params_dict = params or {} 188 | 189 | for k, v in params_dict.items(): 190 | if isinstance(v, (int, float)): 191 | params_dict[k] = str(v) 192 | 193 | def match(request: PreparedRequest) -> Tuple[bool, str]: 194 | reason = "" 195 | request_params = request.params # type: ignore[attr-defined] 196 | request_params_dict = request_params or {} 197 | 198 | if not strict_match: 199 | # filter down to just the params specified in the matcher 200 | request_params_dict = { 201 | k: v for k, v in request_params_dict.items() if k in params_dict 202 | } 203 | 204 | valid = sorted(params_dict.items()) == sorted(request_params_dict.items()) 205 | 206 | if not valid: 207 | reason = f"Parameters do not match. {request_params_dict} doesn't match {params_dict}" 208 | if not strict_match: 209 | reason += ( 210 | "\nYou can use `strict_match=True` to do a strict parameters check." 211 | ) 212 | 213 | return valid, reason 214 | 215 | return match 216 | 217 | 218 | def query_string_matcher(query: Optional[str]) -> Callable[..., Any]: 219 | """ 220 | Matcher to match query string part of request 221 | 222 | :param query: (str), same as constructed by request 223 | :return: (func) matcher 224 | """ 225 | 226 | def match(request: PreparedRequest) -> Tuple[bool, str]: 227 | reason = "" 228 | data = parse_url(request.url or "") 229 | request_query = data.query 230 | 231 | request_qsl = sorted(parse_qsl(request_query)) if request_query else {} 232 | matcher_qsl = sorted(parse_qsl(query)) if query else {} 233 | 234 | valid = not query if request_query is None else request_qsl == matcher_qsl 235 | 236 | if not valid: 237 | reason = ( 238 | "Query string doesn't match. " 239 | f"{dict(request_qsl)} doesn't match {dict(matcher_qsl)}" 240 | ) 241 | 242 | return valid, reason 243 | 244 | return match 245 | 246 | 247 | def request_kwargs_matcher(kwargs: Optional[Mapping[str, Any]]) -> Callable[..., Any]: 248 | """ 249 | Matcher to match keyword arguments provided to request 250 | 251 | :param kwargs: (dict), keyword arguments, same as provided to request 252 | :return: (func) matcher 253 | """ 254 | 255 | def match(request: PreparedRequest) -> Tuple[bool, str]: 256 | reason = "" 257 | kwargs_dict = kwargs or {} 258 | # validate only kwargs that were requested for comparison, skip defaults 259 | req_kwargs = request.req_kwargs # type: ignore[attr-defined] 260 | request_kwargs = {k: v for k, v in req_kwargs.items() if k in kwargs_dict} 261 | 262 | valid = ( 263 | not kwargs_dict 264 | if not request_kwargs 265 | else sorted(kwargs_dict.items()) == sorted(request_kwargs.items()) 266 | ) 267 | 268 | if not valid: 269 | reason = ( 270 | f"Arguments don't match: {request_kwargs} doesn't match {kwargs_dict}" 271 | ) 272 | 273 | return valid, reason 274 | 275 | return match 276 | 277 | 278 | def multipart_matcher( 279 | files: Mapping[str, Any], data: Optional[Mapping[str, str]] = None 280 | ) -> Callable[..., Any]: 281 | """ 282 | Matcher to match 'multipart/form-data' content-type. 283 | This function constructs request body and headers from provided 'data' and 'files' 284 | arguments and compares to actual request 285 | 286 | :param files: (dict), same as provided to request 287 | :param data: (dict), same as provided to request 288 | :return: (func) matcher 289 | """ 290 | if not files: 291 | raise TypeError("files argument cannot be empty") 292 | 293 | prepared = PreparedRequest() 294 | prepared.headers = {"Content-Type": ""} # type: ignore[assignment] 295 | prepared.prepare_body(data=data, files=files) 296 | 297 | def get_boundary(content_type: str) -> str: 298 | """ 299 | Parse 'boundary' value from header. 300 | 301 | :param content_type: (str) headers["Content-Type"] value 302 | :return: (str) boundary value 303 | """ 304 | if "boundary=" not in content_type: 305 | return "" 306 | 307 | return content_type.split("boundary=")[1] 308 | 309 | def match(request: PreparedRequest) -> Tuple[bool, str]: 310 | reason = "multipart/form-data doesn't match. " 311 | if "Content-Type" not in request.headers: 312 | return False, reason + "Request is missing the 'Content-Type' header" 313 | 314 | request_boundary = get_boundary(request.headers["Content-Type"]) 315 | prepared_boundary = get_boundary(prepared.headers["Content-Type"]) 316 | 317 | # replace boundary value in header and in body, since by default 318 | # urllib3.filepost.encode_multipart_formdata dynamically calculates 319 | # random boundary alphanumeric value 320 | request_content_type = request.headers["Content-Type"] 321 | prepared_content_type = prepared.headers["Content-Type"].replace( 322 | prepared_boundary, request_boundary 323 | ) 324 | 325 | request_body = request.body 326 | prepared_body = prepared.body or "" 327 | 328 | if isinstance(prepared_body, bytes): 329 | # since headers always come as str, need to convert to bytes 330 | prepared_boundary = prepared_boundary.encode("utf-8") # type: ignore[assignment] 331 | request_boundary = request_boundary.encode("utf-8") # type: ignore[assignment] 332 | 333 | prepared_body = prepared_body.replace( 334 | prepared_boundary, request_boundary # type: ignore[arg-type] 335 | ) 336 | 337 | headers_valid = prepared_content_type == request_content_type 338 | if not headers_valid: 339 | return ( 340 | False, 341 | reason 342 | + "Request headers['Content-Type'] is different. {} isn't equal to {}".format( 343 | request_content_type, prepared_content_type 344 | ), 345 | ) 346 | 347 | body_valid = prepared_body == request_body 348 | if not body_valid: 349 | return ( 350 | False, 351 | reason 352 | + "Request body differs. {} aren't equal {}".format( # type: ignore[str-bytes-safe] 353 | request_body, prepared_body 354 | ), 355 | ) 356 | 357 | return True, "" 358 | 359 | return match 360 | 361 | 362 | def header_matcher( 363 | headers: Mapping[str, Union[str, Pattern[str]]], strict_match: bool = False 364 | ) -> Callable[..., Any]: 365 | """ 366 | Matcher to match 'headers' argument in request using the responses library. 367 | 368 | Because ``requests`` will send several standard headers in addition to what 369 | was specified by your code, request headers that are additional to the ones 370 | passed to the matcher are ignored by default. You can change this behaviour 371 | by passing ``strict_match=True``. 372 | 373 | :param headers: (dict), same as provided to request 374 | :param strict_match: (bool), whether headers in addition to those specified 375 | in the matcher should cause the match to fail. 376 | :return: (func) matcher 377 | """ 378 | 379 | def _compare_with_regex(request_headers: Union[Mapping[Any, Any], Any]) -> bool: 380 | if strict_match and len(request_headers) != len(headers): 381 | return False 382 | 383 | for k, v in headers.items(): 384 | if request_headers.get(k) is not None: 385 | if isinstance(v, re.Pattern): 386 | if re.match(v, request_headers[k]) is None: 387 | return False 388 | else: 389 | if not v == request_headers[k]: 390 | return False 391 | else: 392 | return False 393 | 394 | return True 395 | 396 | def match(request: PreparedRequest) -> Tuple[bool, str]: 397 | request_headers: Union[Mapping[Any, Any], Any] = request.headers or {} 398 | 399 | if not strict_match: 400 | # filter down to just the headers specified in the matcher 401 | request_headers = {k: v for k, v in request_headers.items() if k in headers} 402 | 403 | valid = _compare_with_regex(request_headers) 404 | 405 | if not valid: 406 | return ( 407 | False, 408 | f"Headers do not match: {request_headers} doesn't match {headers}", 409 | ) 410 | 411 | return valid, "" 412 | 413 | return match 414 | -------------------------------------------------------------------------------- /responses/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. The mypy package uses inline types. 2 | # file must be here according to https://peps.python.org/pep-0561/#packaging-type-information 3 | -------------------------------------------------------------------------------- /responses/registries.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from typing import TYPE_CHECKING 3 | from typing import List 4 | from typing import Optional 5 | from typing import Tuple 6 | 7 | if TYPE_CHECKING: # pragma: no cover 8 | # import only for linter run 9 | from requests import PreparedRequest 10 | 11 | from responses import BaseResponse 12 | 13 | 14 | class FirstMatchRegistry: 15 | def __init__(self) -> None: 16 | self._responses: List["BaseResponse"] = [] 17 | 18 | @property 19 | def registered(self) -> List["BaseResponse"]: 20 | return self._responses 21 | 22 | def reset(self) -> None: 23 | self._responses = [] 24 | 25 | def find( 26 | self, request: "PreparedRequest" 27 | ) -> Tuple[Optional["BaseResponse"], List[str]]: 28 | found = None 29 | found_match = None 30 | match_failed_reasons = [] 31 | for i, response in enumerate(self.registered): 32 | match_result, reason = response.matches(request) 33 | if match_result: 34 | if found is None: 35 | found = i 36 | found_match = response 37 | else: 38 | if self.registered[found].call_count > 0: 39 | # that assumes that some responses were added between calls 40 | self.registered.pop(found) 41 | found_match = response 42 | break 43 | # Multiple matches found. Remove & return the first response. 44 | return self.registered.pop(found), match_failed_reasons 45 | else: 46 | match_failed_reasons.append(reason) 47 | return found_match, match_failed_reasons 48 | 49 | def add(self, response: "BaseResponse") -> "BaseResponse": 50 | if any(response is resp for resp in self.registered): 51 | # if user adds multiple responses that reference the same instance. 52 | # do a comparison by memory allocation address. 53 | # see https://github.com/getsentry/responses/issues/479 54 | response = copy.deepcopy(response) 55 | 56 | self.registered.append(response) 57 | return response 58 | 59 | def remove(self, response: "BaseResponse") -> List["BaseResponse"]: 60 | removed_responses = [] 61 | while response in self.registered: 62 | self.registered.remove(response) 63 | removed_responses.append(response) 64 | return removed_responses 65 | 66 | def replace(self, response: "BaseResponse") -> "BaseResponse": 67 | try: 68 | index = self.registered.index(response) 69 | except ValueError: 70 | raise ValueError(f"Response is not registered for URL {response.url}") 71 | self.registered[index] = response 72 | return response 73 | 74 | 75 | class OrderedRegistry(FirstMatchRegistry): 76 | """Registry where `Response` objects are dependent on the insertion order and invocation index. 77 | 78 | OrderedRegistry applies the rule of first in - first out. Responses should be invoked in 79 | the same order in which they were added to the registry. Otherwise, an error is returned. 80 | """ 81 | 82 | def find( 83 | self, request: "PreparedRequest" 84 | ) -> Tuple[Optional["BaseResponse"], List[str]]: 85 | """Find the next registered `Response` and check if it matches the request. 86 | 87 | Search is performed by taking the first element of the registered responses list 88 | and removing this object (popping from the list). 89 | 90 | Parameters 91 | ---------- 92 | request : PreparedRequest 93 | Request that was caught by the custom adapter. 94 | 95 | Returns 96 | ------- 97 | Tuple[Optional["BaseResponse"], List[str]] 98 | Matched `Response` object and empty list in case of match. 99 | Otherwise, None and a list with reasons for not finding a match. 100 | 101 | """ 102 | 103 | if not self.registered: 104 | return None, ["No more registered responses"] 105 | 106 | response = self.registered.pop(0) 107 | match_result, reason = response.matches(request) 108 | if not match_result: 109 | self.reset() 110 | self.add(response) 111 | reason = ( 112 | "Next 'Response' in the order doesn't match " 113 | f"due to the following reason: {reason}." 114 | ) 115 | return None, [reason] 116 | 117 | return response, [] 118 | -------------------------------------------------------------------------------- /responses/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/responses/9ea86fe6af7626e7b270e128b1e8c521c013d948/responses/tests/__init__.py -------------------------------------------------------------------------------- /responses/tests/test_matchers.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import re 3 | from typing import Any 4 | from typing import List 5 | from unittest.mock import Mock 6 | 7 | import pytest 8 | import requests 9 | from requests.exceptions import ConnectionError 10 | 11 | import responses 12 | from responses import matchers 13 | from responses.tests.test_responses import assert_reset 14 | from responses.tests.test_responses import assert_response 15 | 16 | 17 | def test_body_match_get(): 18 | @responses.activate 19 | def run(): 20 | url = "http://example.com" 21 | responses.add( 22 | responses.GET, 23 | url, 24 | body=b"test", 25 | match=[matchers.body_matcher("123456")], 26 | ) 27 | resp = requests.get("http://example.com", data="123456") 28 | assert_response(resp, "test") 29 | 30 | run() 31 | assert_reset() 32 | 33 | 34 | def test_body_match_post(): 35 | @responses.activate 36 | def run(): 37 | url = "http://example.com" 38 | responses.add( 39 | responses.POST, 40 | url, 41 | body=b"test", 42 | match=[matchers.body_matcher("123456")], 43 | ) 44 | resp = requests.post("http://example.com", data="123456") 45 | assert_response(resp, "test") 46 | 47 | run() 48 | assert_reset() 49 | 50 | 51 | def test_query_string_matcher(): 52 | @responses.activate 53 | def run(): 54 | url = "http://example.com?test=1&foo=bar" 55 | responses.add( 56 | responses.GET, 57 | url, 58 | body=b"test", 59 | match=[matchers.query_string_matcher("test=1&foo=bar")], 60 | ) 61 | resp = requests.get("http://example.com?test=1&foo=bar") 62 | assert_response(resp, "test") 63 | resp = requests.get("http://example.com?foo=bar&test=1") 64 | assert_response(resp, "test") 65 | resp = requests.get("http://example.com/?foo=bar&test=1") 66 | assert_response(resp, "test") 67 | 68 | run() 69 | assert_reset() 70 | 71 | 72 | def test_request_matches_post_params(): 73 | @responses.activate 74 | def run(deprecated): 75 | if deprecated: 76 | json_params_matcher = getattr(responses, "json_params_matcher") 77 | urlencoded_params_matcher = getattr(responses, "urlencoded_params_matcher") 78 | else: 79 | json_params_matcher = matchers.json_params_matcher 80 | urlencoded_params_matcher = matchers.urlencoded_params_matcher 81 | 82 | responses.add( 83 | method=responses.POST, 84 | url="http://example.com/", 85 | body="one", 86 | match=[json_params_matcher({"page": {"name": "first", "type": "json"}})], 87 | ) 88 | responses.add( 89 | method=responses.POST, 90 | url="http://example.com/", 91 | body="two", 92 | match=[urlencoded_params_matcher({"page": "second", "type": "urlencoded"})], 93 | ) 94 | 95 | resp = requests.request( 96 | "POST", 97 | "http://example.com/", 98 | headers={"Content-Type": "x-www-form-urlencoded"}, 99 | data={"page": "second", "type": "urlencoded"}, 100 | ) 101 | assert_response(resp, "two") 102 | 103 | resp = requests.request( 104 | "POST", 105 | "http://example.com/", 106 | headers={"Content-Type": "application/json"}, 107 | json={"page": {"name": "first", "type": "json"}}, 108 | ) 109 | assert_response(resp, "one") 110 | 111 | with pytest.deprecated_call(): 112 | run(deprecated=True) 113 | assert_reset() 114 | 115 | run(deprecated=False) 116 | assert_reset() 117 | 118 | 119 | def test_json_params_matcher_not_strict(): 120 | @responses.activate(assert_all_requests_are_fired=True) 121 | def run(): 122 | responses.add( 123 | method=responses.POST, 124 | url="http://example.com/", 125 | body="one", 126 | match=[ 127 | matchers.json_params_matcher( 128 | {"page": {"type": "json"}}, 129 | strict_match=False, 130 | ) 131 | ], 132 | ) 133 | 134 | resp = requests.request( 135 | "POST", 136 | "http://example.com/", 137 | headers={"Content-Type": "application/json"}, 138 | json={ 139 | "page": {"type": "json", "another": "nested"}, 140 | "not_strict": "must pass", 141 | }, 142 | ) 143 | assert_response(resp, "one") 144 | 145 | run() 146 | assert_reset() 147 | 148 | 149 | def test_json_params_matcher_not_strict_diff_values(): 150 | @responses.activate 151 | def run(): 152 | responses.add( 153 | method=responses.POST, 154 | url="http://example.com/", 155 | body="one", 156 | match=[ 157 | matchers.json_params_matcher( 158 | {"page": {"type": "json", "diff": "value"}}, strict_match=False 159 | ) 160 | ], 161 | ) 162 | 163 | with pytest.raises(ConnectionError) as exc: 164 | requests.request( 165 | "POST", 166 | "http://example.com/", 167 | headers={"Content-Type": "application/json"}, 168 | json={"page": {"type": "json"}, "not_strict": "must pass"}, 169 | ) 170 | assert ( 171 | "- POST http://example.com/ request.body doesn't match: " 172 | "{'page': {'type': 'json'}} doesn't match {'page': {'type': 'json', 'diff': 'value'}}" 173 | ) in str(exc.value) 174 | 175 | run() 176 | assert_reset() 177 | 178 | 179 | def test_failed_matchers_dont_modify_inputs_order_in_error_message(): 180 | json_a = {"array": ["C", "B", "A"]} 181 | json_b = '{"array" : ["B", "A", "C"]}' 182 | mock_request = Mock(body=json_b) 183 | result = matchers.json_params_matcher(json_a)(mock_request) 184 | assert result == ( 185 | False, 186 | ( 187 | "request.body doesn't match: {'array': ['B', 'A', 'C']} " 188 | "doesn't match {'array': ['C', 'B', 'A']}" 189 | ), 190 | ) 191 | 192 | 193 | def test_json_params_matcher_json_list(): 194 | json_a = [{"a": "b"}] 195 | json_b = '[{"a": "b", "c": "d"}]' 196 | mock_request = Mock(body=json_b) 197 | result = matchers.json_params_matcher(json_a)(mock_request) 198 | assert result == ( 199 | False, 200 | "request.body doesn't match: [{'a': 'b', 'c': 'd'}] doesn't match [{'a': 'b'}]", 201 | ) 202 | 203 | 204 | def test_json_params_matcher_json_list_empty(): 205 | json_a: "List[Any]" = [] 206 | json_b = "[]" 207 | mock_request = Mock(body=json_b) 208 | result = matchers.json_params_matcher(json_a)(mock_request) 209 | assert result == (True, "") 210 | 211 | 212 | def test_json_params_matcher_body_is_gzipped(): 213 | json_a = {"foo": 42, "bar": None} 214 | json_b = gzip.compress(b'{"foo": 42, "bar": null}') 215 | mock_request = Mock(body=json_b) 216 | result = matchers.json_params_matcher(json_a)(mock_request) 217 | assert result == (True, "") 218 | 219 | 220 | def test_urlencoded_params_matcher_blank(): 221 | @responses.activate 222 | def run(): 223 | responses.add( 224 | method=responses.POST, 225 | url="http://example.com/", 226 | body="three", 227 | match=[ 228 | matchers.urlencoded_params_matcher( 229 | {"page": "", "type": "urlencoded"}, allow_blank=True 230 | ) 231 | ], 232 | ) 233 | 234 | resp = requests.request( 235 | "POST", 236 | "http://example.com/", 237 | headers={"Content-Type": "x-www-form-urlencoded"}, 238 | data={"page": "", "type": "urlencoded"}, 239 | ) 240 | assert_response(resp, "three") 241 | 242 | run() 243 | assert_reset() 244 | 245 | 246 | def test_query_params_numbers(): 247 | @responses.activate 248 | def run(): 249 | expected_query_params = {"float": 5.0, "int": 2} 250 | responses.add( 251 | responses.GET, 252 | "https://example.com/", 253 | match=[ 254 | matchers.query_param_matcher(expected_query_params), 255 | ], 256 | ) 257 | requests.get("https://example.com", params=expected_query_params) 258 | 259 | run() 260 | assert_reset() 261 | 262 | 263 | def test_query_param_matcher_loose(): 264 | @responses.activate 265 | def run(): 266 | expected_query_params = {"only_one_param": "test"} 267 | responses.add( 268 | responses.GET, 269 | "https://example.com/", 270 | match=[ 271 | matchers.query_param_matcher(expected_query_params, strict_match=False), 272 | ], 273 | ) 274 | requests.get( 275 | "https://example.com", params={"only_one_param": "test", "second": "param"} 276 | ) 277 | 278 | run() 279 | assert_reset() 280 | 281 | 282 | def test_query_param_matcher_loose_fail(): 283 | @responses.activate 284 | def run(): 285 | expected_query_params = {"does_not_exist": "test"} 286 | responses.add( 287 | responses.GET, 288 | "https://example.com/", 289 | match=[ 290 | matchers.query_param_matcher(expected_query_params, strict_match=False), 291 | ], 292 | ) 293 | with pytest.raises(ConnectionError) as exc: 294 | requests.get( 295 | "https://example.com", 296 | params={"only_one_param": "test", "second": "param"}, 297 | ) 298 | 299 | assert ( 300 | "- GET https://example.com/ Parameters do not match. {} doesn't" 301 | " match {'does_not_exist': 'test'}\n" 302 | "You can use `strict_match=True` to do a strict parameters check." 303 | ) in str(exc.value) 304 | 305 | run() 306 | assert_reset() 307 | 308 | 309 | def test_request_matches_empty_body(): 310 | def run(): 311 | with responses.RequestsMock(assert_all_requests_are_fired=True) as rsps: 312 | # test that both json and urlencoded body are empty in matcher and in request 313 | rsps.add( 314 | method=responses.POST, 315 | url="http://example.com/", 316 | body="one", 317 | match=[matchers.json_params_matcher(None)], 318 | ) 319 | 320 | rsps.add( 321 | method=responses.POST, 322 | url="http://example.com/", 323 | body="two", 324 | match=[matchers.urlencoded_params_matcher(None)], 325 | ) 326 | 327 | resp = requests.request("POST", "http://example.com/") 328 | assert_response(resp, "one") 329 | 330 | resp = requests.request( 331 | "POST", 332 | "http://example.com/", 333 | headers={"Content-Type": "x-www-form-urlencoded"}, 334 | ) 335 | assert_response(resp, "two") 336 | 337 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 338 | # test exception raise if matcher body is None but request data is not None 339 | rsps.add( 340 | method=responses.POST, 341 | url="http://example.com/", 342 | body="one", 343 | match=[matchers.json_params_matcher(None)], 344 | ) 345 | 346 | with pytest.raises(ConnectionError) as excinfo: 347 | requests.request( 348 | "POST", 349 | "http://example.com/", 350 | json={"my": "data"}, 351 | headers={"Content-Type": "application/json"}, 352 | ) 353 | 354 | msg = str(excinfo.value) 355 | assert "request.body doesn't match: {'my': 'data'} doesn't match {}" in msg 356 | 357 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 358 | rsps.add( 359 | method=responses.POST, 360 | url="http://example.com/", 361 | body="two", 362 | match=[matchers.urlencoded_params_matcher(None)], 363 | ) 364 | with pytest.raises(ConnectionError) as excinfo: 365 | requests.request( 366 | "POST", 367 | "http://example.com/", 368 | headers={"Content-Type": "x-www-form-urlencoded"}, 369 | data={"page": "second", "type": "urlencoded"}, 370 | ) 371 | msg = str(excinfo.value) 372 | assert ( 373 | "request.body doesn't match: {'page': 'second', " 374 | "'type': 'urlencoded'} doesn't match {}" 375 | ) in msg 376 | 377 | run() 378 | assert_reset() 379 | 380 | 381 | def test_request_matches_params(): 382 | @responses.activate 383 | def run(): 384 | url = "http://example.com/test" 385 | params = {"hello": "world", "I am": "a big test"} 386 | responses.add( 387 | method=responses.GET, 388 | url=url, 389 | body="test", 390 | match=[matchers.query_param_matcher(params)], 391 | match_querystring=False, 392 | ) 393 | 394 | # exchange parameter places for the test 395 | params = { 396 | "I am": "a big test", 397 | "hello": "world", 398 | } 399 | resp = requests.get(url, params=params) 400 | 401 | constructed_url = r"http://example.com/test?I+am=a+big+test&hello=world" 402 | assert resp.url == constructed_url 403 | assert resp.request.url == constructed_url 404 | 405 | resp_params = getattr(resp.request, "params") 406 | assert resp_params == params 407 | 408 | run() 409 | assert_reset() 410 | 411 | 412 | def test_fail_matchers_error(): 413 | """ 414 | Validate that Exception is raised if request does not match responses.matchers 415 | validate matchers.urlencoded_params_matcher 416 | validate matchers.json_params_matcher 417 | validate matchers.query_param_matcher 418 | validate matchers.request_kwargs_matcher 419 | :return: None 420 | """ 421 | 422 | def run(): 423 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 424 | rsps.add( 425 | "POST", 426 | "http://example.com", 427 | match=[matchers.urlencoded_params_matcher({"foo": "bar"})], 428 | ) 429 | rsps.add( 430 | "POST", 431 | "http://example.com", 432 | match=[matchers.json_params_matcher({"fail": "json"})], 433 | ) 434 | 435 | with pytest.raises(ConnectionError) as excinfo: 436 | requests.post("http://example.com", data={"id": "bad"}) 437 | 438 | msg = str(excinfo.value) 439 | assert ( 440 | "request.body doesn't match: {'id': 'bad'} doesn't match {'foo': 'bar'}" 441 | in msg 442 | ) 443 | 444 | assert ( 445 | "request.body doesn't match: JSONDecodeError: Cannot parse request.body" 446 | in msg 447 | ) 448 | 449 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 450 | rsps.add( 451 | "GET", 452 | "http://111.com", 453 | match=[matchers.query_param_matcher({"my": "params"})], 454 | ) 455 | 456 | rsps.add( 457 | method=responses.GET, 458 | url="http://111.com/", 459 | body="two", 460 | match=[matchers.json_params_matcher({"page": "one"})], 461 | ) 462 | 463 | with pytest.raises(ConnectionError) as excinfo: 464 | requests.get( 465 | "http://111.com", params={"id": "bad"}, json={"page": "two"} 466 | ) 467 | 468 | msg = str(excinfo.value) 469 | assert ( 470 | "Parameters do not match. {'id': 'bad'} doesn't match {'my': 'params'}" 471 | in msg 472 | ) 473 | assert ( 474 | "request.body doesn't match: {'page': 'two'} doesn't match {'page': 'one'}" 475 | in msg 476 | ) 477 | 478 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 479 | req_kwargs = { 480 | "stream": True, 481 | "verify": False, 482 | } 483 | rsps.add( 484 | "GET", 485 | "http://111.com", 486 | match=[matchers.request_kwargs_matcher(req_kwargs)], 487 | ) 488 | 489 | with pytest.raises(ConnectionError) as excinfo: 490 | requests.get("http://111.com", stream=True) 491 | 492 | msg = str(excinfo.value) 493 | assert ( 494 | "Arguments don't match: " 495 | "{'stream': True, 'verify': True} doesn't match {'stream': True, 'verify': False}" 496 | ) in msg 497 | 498 | run() 499 | assert_reset() 500 | 501 | 502 | @pytest.mark.parametrize( 503 | "req_file,match_file", 504 | [ 505 | (b"Old World!", "Old World!"), 506 | ("Old World!", b"Old World!"), 507 | (b"Old World!", b"Old World!"), 508 | ("Old World!", "Old World!"), 509 | (b"\xacHello World!", b"\xacHello World!"), 510 | ], 511 | ) 512 | def test_multipart_matcher(req_file, match_file): # type: ignore[misc] 513 | @responses.activate 514 | def run(): 515 | req_data = {"some": "other", "data": "fields"} 516 | responses.add( 517 | responses.POST, 518 | url="http://httpbin.org/post", 519 | match=[ 520 | matchers.multipart_matcher( 521 | files={"file_name": match_file}, data=req_data 522 | ) 523 | ], 524 | ) 525 | resp = requests.post( 526 | "http://httpbin.org/post", data=req_data, files={"file_name": req_file} 527 | ) 528 | assert resp.status_code == 200 529 | 530 | with pytest.raises(TypeError): 531 | responses.add( 532 | responses.POST, 533 | url="http://httpbin.org/post", 534 | match=[matchers.multipart_matcher(files={})], 535 | ) 536 | 537 | run() 538 | assert_reset() 539 | 540 | 541 | def test_multipart_matcher_fail(): 542 | """ 543 | Validate that Exception is raised if request does not match responses.matchers 544 | validate matchers.multipart_matcher 545 | :return: None 546 | """ 547 | 548 | def run(): 549 | # different file contents 550 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 551 | req_data = {"some": "other", "data": "fields"} 552 | req_files = {"file_name": b"Old World!"} 553 | rsps.add( 554 | responses.POST, 555 | url="http://httpbin.org/post", 556 | match=[matchers.multipart_matcher(req_files, data=req_data)], 557 | ) 558 | 559 | with pytest.raises(ConnectionError) as excinfo: 560 | requests.post( 561 | "http://httpbin.org/post", 562 | data=req_data, 563 | files={"file_name": b"New World!"}, 564 | ) 565 | 566 | msg = str(excinfo.value) 567 | assert "multipart/form-data doesn't match. Request body differs." in msg 568 | 569 | assert ( 570 | r'\r\nContent-Disposition: form-data; name="file_name"; ' 571 | r'filename="file_name"\r\n\r\nOld World!\r\n' 572 | ) in msg 573 | assert ( 574 | r'\r\nContent-Disposition: form-data; name="file_name"; ' 575 | r'filename="file_name"\r\n\r\nNew World!\r\n' 576 | ) in msg 577 | 578 | # x-www-form-urlencoded request 579 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 580 | req_data = {"some": "other", "data": "fields"} 581 | req_files = {"file_name": b"Old World!"} 582 | rsps.add( 583 | responses.POST, 584 | url="http://httpbin.org/post", 585 | match=[matchers.multipart_matcher(req_files, data=req_data)], 586 | ) 587 | 588 | with pytest.raises(ConnectionError) as excinfo: 589 | requests.post("http://httpbin.org/post", data=req_data) 590 | 591 | msg = str(excinfo.value) 592 | assert ( 593 | "multipart/form-data doesn't match. Request headers['Content-Type'] is different." 594 | in msg 595 | ) 596 | assert ( 597 | "application/x-www-form-urlencoded isn't equal to multipart/form-data; boundary=" 598 | in msg 599 | ) 600 | 601 | # empty body request 602 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 603 | req_files = {"file_name": b"Old World!"} 604 | rsps.add( 605 | responses.POST, 606 | url="http://httpbin.org/post", 607 | match=[matchers.multipart_matcher(req_files)], 608 | ) 609 | 610 | with pytest.raises(ConnectionError) as excinfo: 611 | requests.post("http://httpbin.org/post") 612 | 613 | msg = str(excinfo.value) 614 | assert "Request is missing the 'Content-Type' header" in msg 615 | 616 | run() 617 | assert_reset() 618 | 619 | 620 | def test_query_string_matcher_raises(): 621 | """ 622 | Validate that Exception is raised if request does not match responses.matchers 623 | validate matchers.query_string_matcher 624 | :return: None 625 | """ 626 | 627 | def run(): 628 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 629 | rsps.add( 630 | "GET", 631 | "http://111.com", 632 | match=[matchers.query_string_matcher("didi=pro")], 633 | ) 634 | 635 | with pytest.raises(ConnectionError) as excinfo: 636 | requests.get("http://111.com", params={"test": "1", "didi": "pro"}) 637 | 638 | msg = str(excinfo.value) 639 | assert ( 640 | "Query string doesn't match. {'didi': 'pro', 'test': '1'} " 641 | "doesn't match {'didi': 'pro'}" 642 | ) in msg 643 | 644 | run() 645 | assert_reset() 646 | 647 | 648 | def test_request_matches_headers(): 649 | @responses.activate 650 | def run(): 651 | url = "http://example.com/" 652 | responses.add( 653 | method=responses.GET, 654 | url=url, 655 | json={"success": True}, 656 | match=[matchers.header_matcher({"Accept": "application/json"})], 657 | ) 658 | 659 | responses.add( 660 | method=responses.GET, 661 | url=url, 662 | body="success", 663 | match=[matchers.header_matcher({"Accept": "text/plain"})], 664 | ) 665 | 666 | # the actual request can contain extra headers (requests always adds some itself anyway) 667 | resp = requests.get( 668 | url, headers={"Accept": "application/json", "Accept-Charset": "utf-8"} 669 | ) 670 | assert_response(resp, body='{"success": true}', content_type="application/json") 671 | 672 | resp = requests.get(url, headers={"Accept": "text/plain"}) 673 | assert_response(resp, body="success", content_type="text/plain") 674 | 675 | run() 676 | assert_reset() 677 | 678 | 679 | def test_request_header_value_mismatch_raises(): 680 | @responses.activate 681 | def run(): 682 | url = "http://example.com/" 683 | responses.add( 684 | method=responses.GET, 685 | url=url, 686 | json={"success": True}, 687 | match=[matchers.header_matcher({"Accept": "application/json"})], 688 | ) 689 | 690 | with pytest.raises(ConnectionError) as excinfo: 691 | requests.get(url, headers={"Accept": "application/xml"}) 692 | 693 | msg = str(excinfo.value) 694 | assert ( 695 | "Headers do not match: {'Accept': 'application/xml'} doesn't match " 696 | "{'Accept': 'application/json'}" 697 | ) in msg 698 | 699 | run() 700 | assert_reset() 701 | 702 | 703 | def test_request_headers_missing_raises(): 704 | @responses.activate 705 | def run(): 706 | url = "http://example.com/" 707 | responses.add( 708 | method=responses.GET, 709 | url=url, 710 | json={"success": True}, 711 | match=[matchers.header_matcher({"x-custom-header": "foo"})], 712 | ) 713 | 714 | with pytest.raises(ConnectionError) as excinfo: 715 | requests.get(url, headers={}) 716 | 717 | msg = str(excinfo.value) 718 | assert ( 719 | "Headers do not match: {} doesn't match {'x-custom-header': 'foo'}" 720 | ) in msg 721 | 722 | run() 723 | assert_reset() 724 | 725 | 726 | def test_request_matches_headers_strict_match(): 727 | @responses.activate 728 | def run(): 729 | url = "http://example.com/" 730 | responses.add( 731 | method=responses.GET, 732 | url=url, 733 | body="success", 734 | match=[ 735 | matchers.header_matcher({"Accept": "text/plain"}, strict_match=True) 736 | ], 737 | ) 738 | 739 | # requests will add some extra headers of its own, so we have to use prepared requests 740 | session = requests.Session() 741 | 742 | # make sure we send *just* the header we're expectin 743 | prepped = session.prepare_request( 744 | requests.Request( 745 | method="GET", 746 | url=url, 747 | ) 748 | ) 749 | prepped.headers.clear() 750 | prepped.headers["Accept"] = "text/plain" 751 | 752 | resp = session.send(prepped) 753 | assert_response(resp, body="success", content_type="text/plain") 754 | 755 | # include the "Accept-Charset" header, which will fail to match 756 | prepped = session.prepare_request( 757 | requests.Request( 758 | method="GET", 759 | url=url, 760 | ) 761 | ) 762 | prepped.headers.clear() 763 | prepped.headers["Accept"] = "text/plain" 764 | prepped.headers["Accept-Charset"] = "utf-8" 765 | 766 | with pytest.raises(ConnectionError) as excinfo: 767 | session.send(prepped) 768 | 769 | msg = str(excinfo.value) 770 | assert ( 771 | "Headers do not match: {'Accept': 'text/plain', 'Accept-Charset': 'utf-8'} " 772 | "doesn't match {'Accept': 'text/plain'}" 773 | ) in msg 774 | 775 | run() 776 | assert_reset() 777 | 778 | 779 | def test_fragment_identifier_matcher(): 780 | @responses.activate 781 | def run(): 782 | responses.add( 783 | responses.GET, 784 | "http://example.com", 785 | match=[matchers.fragment_identifier_matcher("test=1&foo=bar")], 786 | body=b"test", 787 | ) 788 | 789 | resp = requests.get("http://example.com#test=1&foo=bar") 790 | assert_response(resp, "test") 791 | 792 | run() 793 | assert_reset() 794 | 795 | 796 | def test_fragment_identifier_matcher_error(): 797 | @responses.activate 798 | def run(): 799 | responses.add( 800 | responses.GET, 801 | "http://example.com/", 802 | match=[matchers.fragment_identifier_matcher("test=1")], 803 | ) 804 | responses.add( 805 | responses.GET, 806 | "http://example.com/", 807 | match=[matchers.fragment_identifier_matcher(None)], 808 | ) 809 | 810 | with pytest.raises(ConnectionError) as excinfo: 811 | requests.get("http://example.com/#test=2") 812 | 813 | msg = str(excinfo.value) 814 | assert ( 815 | "URL fragment identifier is different: test=1 doesn't match test=2" 816 | ) in msg 817 | assert ( 818 | "URL fragment identifier is different: None doesn't match test=2" 819 | ) in msg 820 | 821 | run() 822 | assert_reset() 823 | 824 | 825 | def test_fragment_identifier_matcher_and_match_querystring(): 826 | @responses.activate 827 | def run(): 828 | url = "http://example.com?ab=xy&zed=qwe#test=1&foo=bar" 829 | responses.add( 830 | responses.GET, 831 | url, 832 | match_querystring=True, 833 | match=[matchers.fragment_identifier_matcher("test=1&foo=bar")], 834 | body=b"test", 835 | ) 836 | 837 | # two requests to check reversed order of fragment identifier 838 | resp = requests.get("http://example.com?ab=xy&zed=qwe#test=1&foo=bar") 839 | assert_response(resp, "test") 840 | resp = requests.get("http://example.com?zed=qwe&ab=xy#foo=bar&test=1") 841 | assert_response(resp, "test") 842 | 843 | run() 844 | assert_reset() 845 | 846 | 847 | def test_matchers_under_requests_mock_object(): 848 | def run(): 849 | # ensure all access to responses or matchers is only going 850 | # through the RequestsMock instance in the context manager 851 | responses = None # noqa: F841 852 | matchers = None # noqa: F841 853 | from responses import RequestsMock 854 | 855 | with RequestsMock(assert_all_requests_are_fired=True) as rsps: 856 | url = "http://example.com" 857 | rsps.add( 858 | rsps.GET, 859 | url, 860 | body=b"test", 861 | match=[rsps.matchers.body_matcher("123456")], 862 | ) 863 | resp = requests.get("http://example.com", data="123456") 864 | assert_response(resp, "test") 865 | 866 | run() 867 | assert_reset() 868 | 869 | 870 | class TestHeaderWithRegex: 871 | @property 872 | def url(self): # type: ignore[misc] 873 | return "http://example.com/" 874 | 875 | def _register(self): 876 | responses.add( 877 | method=responses.GET, 878 | url=self.url, 879 | body="success", 880 | match=[ 881 | matchers.header_matcher( 882 | { 883 | "Accept": "text/plain", 884 | "Message-Signature": re.compile(r'signature="\S+",created=\d+'), 885 | }, 886 | strict_match=True, 887 | ) 888 | ], 889 | ) 890 | 891 | def test_request_matches_headers_regex(self): 892 | @responses.activate 893 | def run(): 894 | # this one can not use common _register method because different headers 895 | responses.add( 896 | method=responses.GET, 897 | url=self.url, 898 | json={"success": True}, 899 | match=[ 900 | matchers.header_matcher( 901 | { 902 | "Message-Signature": re.compile( 903 | r'signature="\S+",created=\d+' 904 | ), 905 | "Authorization": "Bearer API_TOKEN", 906 | }, 907 | strict_match=False, 908 | ) 909 | ], 910 | ) 911 | # the actual request can contain extra headers (requests always adds some itself anyway) 912 | resp = requests.get( 913 | self.url, 914 | headers={ 915 | "Message-Signature": 'signature="abc",created=1243', 916 | "Authorization": "Bearer API_TOKEN", 917 | }, 918 | ) 919 | assert_response( 920 | resp, body='{"success": true}', content_type="application/json" 921 | ) 922 | 923 | run() 924 | assert_reset() 925 | 926 | def test_request_matches_headers_regex_strict_match_regex_failed(self): 927 | @responses.activate 928 | def run(): 929 | self._register() 930 | session = requests.Session() 931 | # requests will add some extra headers of its own, so we have to use prepared requests 932 | prepped = session.prepare_request( 933 | requests.Request( 934 | method="GET", 935 | url=self.url, 936 | ) 937 | ) 938 | prepped.headers.clear() 939 | prepped.headers["Accept"] = "text/plain" 940 | prepped.headers["Message-Signature"] = 'signature="123",created=abc' 941 | with pytest.raises(ConnectionError) as excinfo: 942 | session.send(prepped) 943 | msg = str(excinfo.value) 944 | assert ( 945 | "Headers do not match: {'Accept': 'text/plain', 'Message-Signature': " 946 | """'signature="123",created=abc'} """ 947 | "doesn't match {'Accept': 'text/plain', 'Message-Signature': " 948 | "re.compile('signature=\"\\\\S+\",created=\\\\d+')}" 949 | ) in msg 950 | 951 | run() 952 | assert_reset() 953 | 954 | def test_request_matches_headers_regex_strict_match_mismatched_field(self): 955 | @responses.activate 956 | def run(): 957 | self._register() 958 | # requests will add some extra headers of its own, so we have to use prepared requests 959 | session = requests.Session() 960 | prepped = session.prepare_request( 961 | requests.Request( 962 | method="GET", 963 | url=self.url, 964 | ) 965 | ) 966 | prepped.headers.clear() 967 | prepped.headers["Accept"] = "text/plain" 968 | prepped.headers["Accept-Charset"] = "utf-8" 969 | # "Accept-Charset" header will fail to match to "Message-Signature" 970 | with pytest.raises(ConnectionError) as excinfo: 971 | session.send(prepped) 972 | msg = str(excinfo.value) 973 | assert ( 974 | "Headers do not match: {'Accept': 'text/plain', 'Accept-Charset': 'utf-8'} " 975 | "doesn't match {'Accept': 'text/plain', 'Message-Signature': " 976 | "re.compile('signature=\"\\\\S+\",created=\\\\d+')}" 977 | ) in msg 978 | 979 | run() 980 | assert_reset() 981 | 982 | def test_request_matches_headers_regex_strict_match_mismatched_number(self): 983 | @responses.activate 984 | def run(): 985 | self._register() 986 | # requests will add some extra headers of its own, so we have to use prepared requests 987 | session = requests.Session() 988 | # include the "Accept-Charset" header, which will fail to match 989 | prepped = session.prepare_request( 990 | requests.Request( 991 | method="GET", 992 | url=self.url, 993 | ) 994 | ) 995 | prepped.headers.clear() 996 | prepped.headers["Accept"] = "text/plain" 997 | prepped.headers["Accept-Charset"] = "utf-8" 998 | prepped.headers["Message-Signature"] = 'signature="abc",created=1243' 999 | with pytest.raises(ConnectionError) as excinfo: 1000 | session.send(prepped) 1001 | msg = str(excinfo.value) 1002 | assert ( 1003 | "Headers do not match: {'Accept': 'text/plain', 'Accept-Charset': 'utf-8', " 1004 | """'Message-Signature': 'signature="abc",created=1243'} """ 1005 | "doesn't match {'Accept': 'text/plain', 'Message-Signature': " 1006 | "re.compile('signature=\"\\\\S+\",created=\\\\d+')}" 1007 | ) in msg 1008 | 1009 | run() 1010 | assert_reset() 1011 | 1012 | def test_request_matches_headers_regex_strict_match_positive(self): 1013 | @responses.activate 1014 | def run(): 1015 | self._register() 1016 | # requests will add some extra headers of its own, so we have to use prepared requests 1017 | session = requests.Session() 1018 | prepped = session.prepare_request( 1019 | requests.Request( 1020 | method="GET", 1021 | url=self.url, 1022 | ) 1023 | ) 1024 | prepped.headers.clear() 1025 | prepped.headers["Accept"] = "text/plain" 1026 | prepped.headers["Message-Signature"] = 'signature="abc",created=1243' 1027 | resp = session.send(prepped) 1028 | assert_response(resp, body="success", content_type="text/plain") 1029 | 1030 | run() 1031 | assert_reset() 1032 | -------------------------------------------------------------------------------- /responses/tests/test_multithreading.py: -------------------------------------------------------------------------------- 1 | """ 2 | Separate file for multithreading since it takes time to run 3 | """ 4 | import threading 5 | 6 | import pytest 7 | import requests 8 | 9 | import responses 10 | 11 | 12 | @pytest.mark.parametrize("execution_number", range(10)) 13 | def test_multithreading_lock(execution_number): # type: ignore[misc] 14 | """Reruns test multiple times since error is random and 15 | depends on CPU and can lead to false positive result. 16 | 17 | """ 18 | n_threads = 10 19 | n_requests = 30 20 | with responses.RequestsMock() as m: 21 | for j in range(n_threads): 22 | for i in range(n_requests): 23 | m.add(url=f"http://example.com/example{i}", method="GET") 24 | 25 | def fun(): 26 | for req in range(n_requests): 27 | requests.get(f"http://example.com/example{req}") 28 | 29 | threads = [ 30 | threading.Thread(name=f"example{i}", target=fun) for i in range(n_threads) 31 | ] 32 | for thread in threads: 33 | thread.start() 34 | for thread in threads: 35 | thread.join() 36 | -------------------------------------------------------------------------------- /responses/tests/test_recorder.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | import requests 5 | import tomli_w 6 | import yaml 7 | 8 | import responses 9 | from responses import _recorder 10 | from responses._recorder import _dump 11 | 12 | try: 13 | import tomli as _toml 14 | except ImportError: 15 | # python 3.11+ 16 | import tomllib as _toml # type: ignore[no-redef] 17 | 18 | 19 | def get_data(host, port): 20 | data = { 21 | "responses": [ 22 | { 23 | "response": { 24 | "method": "GET", 25 | "url": f"http://{host}:{port}/404", 26 | "headers": {"x": "foo"}, 27 | "body": "404 Not Found", 28 | "status": 404, 29 | "content_type": "text/plain", 30 | "auto_calculate_content_length": False, 31 | } 32 | }, 33 | { 34 | "response": { 35 | "method": "GET", 36 | "url": f"http://{host}:{port}/status/wrong", 37 | "headers": {"x": "foo"}, 38 | "body": "Invalid status code", 39 | "status": 400, 40 | "content_type": "text/plain", 41 | "auto_calculate_content_length": False, 42 | } 43 | }, 44 | { 45 | "response": { 46 | "method": "GET", 47 | "url": f"http://{host}:{port}/500", 48 | "headers": {"x": "foo"}, 49 | "body": "500 Internal Server Error", 50 | "status": 500, 51 | "content_type": "text/plain", 52 | "auto_calculate_content_length": False, 53 | } 54 | }, 55 | { 56 | "response": { 57 | "method": "PUT", 58 | "url": f"http://{host}:{port}/202", 59 | "body": "OK", 60 | "status": 202, 61 | "content_type": "text/plain", 62 | "auto_calculate_content_length": False, 63 | } 64 | }, 65 | ] 66 | } 67 | return data 68 | 69 | 70 | class TestRecord: 71 | def setup_method(self): 72 | self.out_file = Path("response_record") 73 | if self.out_file.exists(): 74 | self.out_file.unlink() # pragma: no cover 75 | 76 | assert not self.out_file.exists() 77 | 78 | def test_recorder(self, httpserver): 79 | url202, url400, url404, url500 = self.prepare_server(httpserver) 80 | 81 | def another(): 82 | requests.get(url500) 83 | requests.put(url202) 84 | 85 | @_recorder.record(file_path=self.out_file) 86 | def run(): 87 | requests.get(url404) 88 | requests.get(url400) 89 | another() 90 | 91 | run() 92 | 93 | with open(self.out_file) as file: 94 | data = yaml.safe_load(file) 95 | assert data == get_data(httpserver.host, httpserver.port) 96 | 97 | def test_recorder_toml(self, httpserver): 98 | custom_recorder = _recorder.Recorder() 99 | 100 | def dump_to_file(file_path, registered): 101 | with open(file_path, "wb") as file: 102 | _dump(registered, file, tomli_w.dump) # type: ignore[arg-type] 103 | 104 | custom_recorder.dump_to_file = dump_to_file # type: ignore[assignment] 105 | 106 | url202, url400, url404, url500 = self.prepare_server(httpserver) 107 | 108 | def another(): 109 | requests.get(url500) 110 | requests.put(url202) 111 | 112 | @custom_recorder.record(file_path=self.out_file) 113 | def run(): 114 | requests.get(url404) 115 | requests.get(url400) 116 | another() 117 | 118 | run() 119 | 120 | with open(self.out_file, "rb") as file: 121 | data = _toml.load(file) 122 | 123 | assert data == get_data(httpserver.host, httpserver.port) 124 | 125 | def prepare_server(self, httpserver): 126 | httpserver.expect_request("/500").respond_with_data( 127 | "500 Internal Server Error", 128 | status=500, 129 | content_type="text/plain", 130 | headers={"x": "foo"}, 131 | ) 132 | httpserver.expect_request("/202").respond_with_data( 133 | "OK", 134 | status=202, 135 | content_type="text/plain", 136 | ) 137 | httpserver.expect_request("/404").respond_with_data( 138 | "404 Not Found", 139 | status=404, 140 | content_type="text/plain", 141 | headers={"x": "foo"}, 142 | ) 143 | httpserver.expect_request("/status/wrong").respond_with_data( 144 | "Invalid status code", 145 | status=400, 146 | content_type="text/plain", 147 | headers={"x": "foo"}, 148 | ) 149 | url500 = httpserver.url_for("/500") 150 | url202 = httpserver.url_for("/202") 151 | url404 = httpserver.url_for("/404") 152 | url400 = httpserver.url_for("/status/wrong") 153 | return url202, url400, url404, url500 154 | 155 | def test_use_recorder_without_decorator(self, httpserver): 156 | """I want to be able to record in the REPL.""" 157 | url202, url400, url404, url500 = self.prepare_server(httpserver) 158 | 159 | _recorder.recorder.start() 160 | 161 | def another(): 162 | requests.get(url500) 163 | requests.put(url202) 164 | 165 | def run(): 166 | requests.get(url404) 167 | requests.get(url400) 168 | another() 169 | 170 | run() 171 | 172 | _recorder.recorder.stop() 173 | _recorder.recorder.dump_to_file(self.out_file) 174 | 175 | with open(self.out_file) as file: 176 | data = yaml.safe_load(file) 177 | assert data == get_data(httpserver.host, httpserver.port) 178 | 179 | # Now, we test that the recorder is properly reset 180 | assert _recorder.recorder.get_registry().registered 181 | _recorder.recorder.reset() 182 | assert not _recorder.recorder.get_registry().registered 183 | 184 | 185 | class TestReplay: 186 | def setup_method(self): 187 | self.out_file = Path("response_record") 188 | 189 | def teardown_method(self): 190 | if self.out_file.exists(): 191 | self.out_file.unlink() 192 | 193 | assert not self.out_file.exists() 194 | 195 | @pytest.mark.parametrize("parser", (yaml, tomli_w)) 196 | def test_add_from_file(self, parser): # type: ignore[misc] 197 | if parser == yaml: 198 | with open(self.out_file, "w") as file: 199 | parser.dump(get_data("example.com", "8080"), file) 200 | else: 201 | with open(self.out_file, "wb") as file: # type: ignore[assignment] 202 | parser.dump(get_data("example.com", "8080"), file) 203 | 204 | @responses.activate 205 | def run(): 206 | responses.patch("http://httpbin.org") 207 | if parser == tomli_w: 208 | 209 | def _parse_resp_f(file_path): 210 | with open(file_path, "rb") as file: 211 | data = _toml.load(file) 212 | return data 213 | 214 | responses.mock._parse_response_file = _parse_resp_f # type: ignore[method-assign] 215 | 216 | responses._add_from_file(file_path=self.out_file) 217 | responses.post("http://httpbin.org/form") 218 | 219 | assert responses.registered()[0].url == "http://httpbin.org/" 220 | assert responses.registered()[1].url == "http://example.com:8080/404" 221 | assert ( 222 | responses.registered()[2].url == "http://example.com:8080/status/wrong" 223 | ) 224 | assert responses.registered()[3].url == "http://example.com:8080/500" 225 | assert responses.registered()[4].url == "http://example.com:8080/202" 226 | assert responses.registered()[5].url == "http://httpbin.org/form" 227 | 228 | assert responses.registered()[0].method == "PATCH" 229 | assert responses.registered()[2].method == "GET" 230 | assert responses.registered()[4].method == "PUT" 231 | assert responses.registered()[5].method == "POST" 232 | 233 | assert responses.registered()[2].status == 400 234 | assert responses.registered()[3].status == 500 235 | 236 | assert responses.registered()[3].body == "500 Internal Server Error" 237 | 238 | assert responses.registered()[3].content_type == "text/plain" 239 | 240 | run() 241 | -------------------------------------------------------------------------------- /responses/tests/test_registries.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | from requests.exceptions import ConnectionError 4 | 5 | import responses 6 | from responses import registries 7 | from responses.registries import OrderedRegistry 8 | from responses.tests.test_responses import assert_reset 9 | 10 | 11 | def test_set_registry_not_empty(): 12 | class CustomRegistry(registries.FirstMatchRegistry): 13 | pass 14 | 15 | @responses.activate 16 | def run(): 17 | url = "http://fizzbuzz/foo" 18 | responses.add(method=responses.GET, url=url) 19 | with pytest.raises(AttributeError) as excinfo: 20 | responses.mock._set_registry(CustomRegistry) 21 | msg = str(excinfo.value) 22 | assert "Cannot replace Registry, current registry has responses" in msg 23 | 24 | run() 25 | assert_reset() 26 | 27 | 28 | def test_set_registry(): 29 | class CustomRegistry(registries.FirstMatchRegistry): 30 | pass 31 | 32 | @responses.activate(registry=CustomRegistry) 33 | def run_with_registry(): 34 | assert type(responses.mock.get_registry()) == CustomRegistry 35 | 36 | @responses.activate 37 | def run(): 38 | # test that registry does not leak to another test 39 | assert type(responses.mock.get_registry()) == registries.FirstMatchRegistry 40 | 41 | run_with_registry() 42 | run() 43 | assert_reset() 44 | 45 | 46 | def test_set_registry_reversed(): 47 | """See https://github.com/getsentry/responses/issues/563""" 48 | 49 | class CustomRegistry(registries.FirstMatchRegistry): 50 | pass 51 | 52 | @responses.activate 53 | def run(): 54 | # test that registry does not leak to another test 55 | assert type(responses.mock.get_registry()) == registries.FirstMatchRegistry 56 | 57 | @responses.activate(registry=CustomRegistry) 58 | def run_with_registry(): 59 | assert type(responses.mock.get_registry()) == CustomRegistry 60 | 61 | run() 62 | run_with_registry() 63 | assert_reset() 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_registry_async(): # type: ignore[misc] 68 | class CustomRegistry(registries.FirstMatchRegistry): 69 | pass 70 | 71 | @responses.activate 72 | async def run(): 73 | # test that registry does not leak to another test 74 | assert type(responses.mock.get_registry()) == registries.FirstMatchRegistry 75 | 76 | @responses.activate(registry=CustomRegistry) 77 | async def run_with_registry(): 78 | assert type(responses.mock.get_registry()) == CustomRegistry 79 | 80 | await run() 81 | await run_with_registry() 82 | assert_reset() 83 | 84 | 85 | def test_set_registry_context_manager(): 86 | def run(): 87 | class CustomRegistry(registries.FirstMatchRegistry): 88 | pass 89 | 90 | with responses.RequestsMock( 91 | assert_all_requests_are_fired=False, registry=CustomRegistry 92 | ) as rsps: 93 | assert type(rsps.get_registry()) == CustomRegistry 94 | assert type(responses.mock.get_registry()) == registries.FirstMatchRegistry 95 | 96 | run() 97 | assert_reset() 98 | 99 | 100 | def test_registry_reset(): 101 | def run(): 102 | class CustomRegistry(registries.FirstMatchRegistry): 103 | pass 104 | 105 | with responses.RequestsMock( 106 | assert_all_requests_are_fired=False, registry=CustomRegistry 107 | ) as rsps: 108 | rsps.get_registry().reset() 109 | assert not rsps.registered() 110 | 111 | run() 112 | assert_reset() 113 | 114 | 115 | class TestOrderedRegistry: 116 | def test_invocation_index(self): 117 | @responses.activate(registry=OrderedRegistry) 118 | def run(): 119 | responses.add( 120 | responses.GET, 121 | "http://twitter.com/api/1/foobar", 122 | status=666, 123 | ) 124 | responses.add( 125 | responses.GET, 126 | "http://twitter.com/api/1/foobar", 127 | status=667, 128 | ) 129 | responses.add( 130 | responses.GET, 131 | "http://twitter.com/api/1/foobar", 132 | status=668, 133 | ) 134 | responses.add( 135 | responses.GET, 136 | "http://twitter.com/api/1/foobar", 137 | status=669, 138 | ) 139 | 140 | resp = requests.get("http://twitter.com/api/1/foobar") 141 | assert resp.status_code == 666 142 | resp = requests.get("http://twitter.com/api/1/foobar") 143 | assert resp.status_code == 667 144 | resp = requests.get("http://twitter.com/api/1/foobar") 145 | assert resp.status_code == 668 146 | resp = requests.get("http://twitter.com/api/1/foobar") 147 | assert resp.status_code == 669 148 | 149 | run() 150 | assert_reset() 151 | 152 | def test_not_match(self): 153 | @responses.activate(registry=OrderedRegistry) 154 | def run(): 155 | responses.add( 156 | responses.GET, 157 | "http://twitter.com/api/1/foobar", 158 | json={"msg": "not found"}, 159 | status=667, 160 | ) 161 | responses.add( 162 | responses.GET, 163 | "http://twitter.com/api/1/barfoo", 164 | json={"msg": "not found"}, 165 | status=404, 166 | ) 167 | responses.add( 168 | responses.GET, 169 | "http://twitter.com/api/1/foobar", 170 | json={"msg": "OK"}, 171 | status=200, 172 | ) 173 | 174 | resp = requests.get("http://twitter.com/api/1/foobar") 175 | assert resp.status_code == 667 176 | 177 | with pytest.raises(ConnectionError) as excinfo: 178 | requests.get("http://twitter.com/api/1/foobar") 179 | 180 | msg = str(excinfo.value) 181 | assert ( 182 | "- GET http://twitter.com/api/1/barfoo Next 'Response' in the " 183 | "order doesn't match due to the following reason: URL does not match" 184 | ) in msg 185 | 186 | run() 187 | assert_reset() 188 | 189 | def test_empty_registry(self): 190 | @responses.activate(registry=OrderedRegistry) 191 | def run(): 192 | with pytest.raises(ConnectionError): 193 | requests.get("http://twitter.com/api/1/foobar") 194 | 195 | run() 196 | assert_reset() 197 | -------------------------------------------------------------------------------- /scripts/bump-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eux 3 | 4 | cd "$(dirname "$0")/.." 5 | 6 | OLD_VERSION="${1}" 7 | NEW_VERSION="${2}" 8 | 9 | echo "Current version: $OLD_VERSION" 10 | echo "Bumping version: $NEW_VERSION" 11 | 12 | function replace() { 13 | ! grep "$2" "$3" 14 | sed -e "s/$1/$2/g" "$3" > "$3.tmp" # -i is non-portable 15 | mv "$3.tmp" "$3" 16 | grep "$2" "$3" # verify that replacement was successful 17 | } 18 | 19 | replace 'version="[^"]*"' "version=\"$NEW_VERSION\"" ./setup.py 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | responses 4 | ========= 5 | 6 | A utility library for mocking out the `requests` Python library. 7 | 8 | :copyright: (c) 2015 David Cramer 9 | :license: Apache 2.0 10 | """ 11 | 12 | from setuptools import setup 13 | 14 | install_requires = [ 15 | "requests>=2.30.0,<3.0", 16 | "urllib3>=1.25.10,<3.0", 17 | "pyyaml", 18 | ] 19 | 20 | tests_require = [ 21 | "pytest>=7.0.0", 22 | "coverage >= 6.0.0", 23 | "pytest-cov", 24 | "pytest-asyncio", 25 | "pytest-httpserver", 26 | "flake8", 27 | "types-PyYAML", 28 | "types-requests", 29 | "mypy", 30 | # for check of different parsers in recorder 31 | "tomli; python_version < '3.11'", 32 | "tomli-w", 33 | ] 34 | 35 | extras_require = {"tests": tests_require} 36 | 37 | setup( 38 | name="responses", 39 | version="0.25.7", 40 | author="David Cramer", 41 | description="A utility library for mocking out the `requests` Python library.", 42 | url="https://github.com/getsentry/responses", 43 | project_urls={ 44 | "Bug Tracker": "https://github.com/getsentry/responses/issues", 45 | "Changes": "https://github.com/getsentry/responses/blob/master/CHANGES", 46 | "Documentation": "https://github.com/getsentry/responses/blob/master/README.rst", 47 | "Source Code": "https://github.com/getsentry/responses", 48 | }, 49 | license="Apache 2.0", 50 | long_description=open("README.rst", encoding="utf-8").read(), 51 | long_description_content_type="text/x-rst", 52 | packages=["responses"], 53 | package_data={ 54 | "responses": ["py.typed"], 55 | }, 56 | zip_safe=False, 57 | python_requires=">=3.8", 58 | install_requires=install_requires, 59 | extras_require=extras_require, 60 | classifiers=[ 61 | "Intended Audience :: Developers", 62 | "Intended Audience :: System Administrators", 63 | "Operating System :: OS Independent", 64 | "Programming Language :: Python", 65 | "Programming Language :: Python :: 3", 66 | "Programming Language :: Python :: 3.8", 67 | "Programming Language :: Python :: 3.9", 68 | "Programming Language :: Python :: 3.10", 69 | "Programming Language :: Python :: 3.11", 70 | "Programming Language :: Python :: 3.12", 71 | "Programming Language :: Python :: 3.13", 72 | "Topic :: Software Development", 73 | ], 74 | ) 75 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,py310,py311,py312,py313,mypy,precom 3 | 4 | [pytest] 5 | filterwarnings = 6 | error 7 | default::DeprecationWarning 8 | 9 | [testenv] 10 | extras = tests 11 | commands = 12 | pytest . --asyncio-mode=auto --cov responses --cov-report term-missing {posargs} 13 | 14 | 15 | [testenv:mypy] 16 | description = Check types using 'mypy' 17 | basepython = python3.10 18 | commands = 19 | python -m mypy --config-file=mypy.ini -p responses 20 | # see https://github.com/getsentry/responses/issues/556 21 | python -m mypy --config-file=mypy.ini --namespace-packages -p responses 22 | 23 | [testenv:precom] 24 | description = Run pre-commit hooks (black, flake, etc) 25 | basepython = python3.10 26 | deps = pre-commit>=2.9.2 27 | commands = 28 | pre-commit run --all-files 29 | --------------------------------------------------------------------------------