├── .github ├── dependabot.yml └── workflows │ ├── check-docs.yml │ ├── lint.yml │ ├── publish-docs.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── Taskfile.yaml ├── docs ├── api.md ├── examples.md ├── guide.md ├── img │ └── respx.png ├── index.md ├── migrate.md ├── mocking.md ├── stylesheets │ └── slate.css ├── upgrade.md └── versions │ └── 0.14.0 │ ├── api.md │ └── mocking.md ├── flake.lock ├── flake.nix ├── mkdocs.yaml ├── noxfile.py ├── respx ├── __init__.py ├── __version__.py ├── api.py ├── fixtures.py ├── handlers.py ├── mocks.py ├── models.py ├── patterns.py ├── plugin.py ├── py.typed ├── router.py ├── transports.py ├── types.py └── utils.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── test_api.py ├── test_mock.py ├── test_patterns.py ├── test_plugin.py ├── test_remote.py ├── test_router.py ├── test_stats.py ├── test_transports.py └── test_utils.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | timezone: "Europe/Stockholm" 8 | -------------------------------------------------------------------------------- /.github/workflows/check-docs.yml: -------------------------------------------------------------------------------- 1 | name: check-docs 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'docs/**' 7 | - '.github/workflows/check-docs.yml' 8 | 9 | jobs: 10 | check-docs: 11 | name: Check Docs 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.10" 18 | - run: pip install nox 19 | - name: Run mypy 20 | run: nox -N -s docs 21 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | name: Check Linting 9 | uses: less-action/reusables/.github/workflows/pre-commit.yaml@v8 10 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: publish-docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'docs/**' 9 | - '.github/workflows/docs.yml' 10 | 11 | jobs: 12 | build: 13 | name: Build & Publish 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.10" 20 | - run: pip install nox 21 | - name: Build 22 | run: nox -N -s docs 23 | - name: Publish 24 | if: github.repository_owner == 'lundberg' 25 | run: | 26 | git config user.email ${{ secrets.GITHUB_EMAIL }} 27 | git remote set-url origin https://${{ secrets.GITHUB_USER }}:${{ secrets.GITHUB_PAGES_TOKEN }}@github.com/lundberg/respx.git 28 | ./.nox/docs/bin/mkdocs gh-deploy --force 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - 'docs/**' 9 | pull_request: 10 | paths-ignore: 11 | - 'docs/**' 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | 17 | env: 18 | FORCE_COLOR: 1 19 | PYTHONUNBUFFERED: "1" 20 | 21 | jobs: 22 | test: 23 | name: Test Python ${{ matrix.python-version }} 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | max-parallel: 4 28 | matrix: 29 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | - run: pip install nox 37 | - name: Test 38 | run: nox -N -s test-${{ matrix.python-version }} -- -v 39 | - name: Upload coverage report 40 | uses: codecov/codecov-action@v4.5.0 41 | with: 42 | name: Python ${{ matrix.python-version }} 43 | files: ./coverage.xml 44 | fail_ci_if_error: true 45 | slug: lundberg/respx 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | env: 48 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 49 | 50 | check-types: 51 | name: Check Typing 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: actions/setup-python@v5 56 | with: 57 | python-version: "3.8" 58 | - run: pip install nox 59 | - name: Run mypy 60 | run: nox -N -s mypy 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Distribution / packaging 6 | .env/ 7 | env/ 8 | venv/ 9 | build/ 10 | dist/ 11 | site/ 12 | eggs/ 13 | *.egg-info/ 14 | 15 | # Unit test / coverage reports 16 | .nox/ 17 | .coverage 18 | .coverage.* 19 | .mypy_cache/ 20 | .pytest_cache/ 21 | coverage.xml 22 | 23 | # Editor config 24 | .idea 25 | .tags 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.11 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.5.0 6 | hooks: 7 | - id: check-added-large-files 8 | - id: check-case-conflict 9 | - id: check-merge-conflict 10 | - id: check-toml 11 | - id: end-of-file-fixer 12 | - id: trailing-whitespace 13 | - id: debug-statements 14 | - id: detect-private-key 15 | - repo: https://github.com/asottile/pyupgrade 16 | rev: v3.15.2 17 | hooks: 18 | - id: pyupgrade 19 | args: 20 | - --py37-plus 21 | - --keep-runtime-typing 22 | - repo: https://github.com/pycqa/autoflake 23 | rev: v2.3.1 24 | hooks: 25 | - id: autoflake 26 | args: 27 | - --in-place 28 | - --remove-all-unused-imports 29 | - --ignore-init-module-imports 30 | - repo: https://github.com/pycqa/isort 31 | rev: 5.13.2 32 | hooks: 33 | - id: isort 34 | - repo: https://github.com/psf/black 35 | rev: 23.3.0 36 | hooks: 37 | - id: black 38 | - repo: https://github.com/PyCQA/flake8 39 | rev: 7.0.0 40 | hooks: 41 | - id: flake8 42 | additional_dependencies: 43 | - flake8-bugbear 44 | - flake8-comprehensions 45 | - flake8-tidy-imports 46 | - flake8-print 47 | - flake8-pytest-style 48 | - flake8-datetimez 49 | - repo: https://github.com/sirosen/check-jsonschema 50 | rev: 0.28.1 51 | hooks: 52 | - id: check-github-workflows 53 | - repo: https://github.com/asottile/yesqa 54 | rev: v1.5.0 55 | hooks: 56 | - id: yesqa 57 | additional_dependencies: 58 | - flake8-bugbear 59 | - flake8-comprehensions 60 | - flake8-tidy-imports 61 | - flake8-print 62 | - flake8-pytest-style 63 | - flake8-datetimez 64 | - repo: https://github.com/pre-commit/mirrors-prettier 65 | rev: "v4.0.0-alpha.8" 66 | hooks: 67 | - id: prettier 68 | alias: format-markdown 69 | types: [markdown] 70 | args: 71 | - --parser=markdown 72 | - --print-width=88 73 | - --prose-wrap=always 74 | - repo: https://github.com/mgedmin/check-manifest 75 | rev: "0.49" 76 | hooks: 77 | - id: check-manifest 78 | args: ["--no-build-isolation"] 79 | 80 | exclude: | 81 | (?x)( 82 | /( 83 | \.eggs 84 | | \.git 85 | | \.hg 86 | | \.mypy_cache 87 | | \.pytest_cache 88 | | \.nox 89 | | \.tox 90 | | \.venv 91 | | _build 92 | | buck-out 93 | | build 94 | | dist 95 | )/ 96 | | docs 97 | | LICENSE\.md 98 | ) 99 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and 6 | this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.22.0] - 2024-12-19 9 | 10 | ### Fixed 11 | 12 | - Support HTTPX 0.28.0, thanks @ndhansen (#278) 13 | 14 | ### Removed 15 | 16 | - Drop support for Python 3.7, to align with HTTPX 0.25.0 (#280) 17 | 18 | ### CI 19 | 20 | - Update CI test to not fail fast and cancel workflows, thanks @flaeppe (#269) 21 | - Add dependabot to check GitHub actions packages, thanks @flaeppe (#268) 22 | - Add Python 3.13 to test suite, thanks @jairhenrique (#283) 23 | 24 | ## [0.21.1] - 2024-03-27 25 | 26 | ### Fixed 27 | 28 | - Fix `files` pattern not handling `str` and `BytesIO`, thanks @pierremonico for input 29 | (#260) 30 | 31 | ### Added 32 | 33 | - Add support for `None` values in `data` pattern, thanks @slingshotvfx for issue (#259) 34 | 35 | ## [0.21.0] - 2024-03-19 36 | 37 | ### Fixed 38 | 39 | - Fix matching request data when files are provided, thanks @ziima for input (#252) 40 | 41 | ### Added 42 | 43 | - Add support for data\_\_contains lookup (#252) 44 | - Add `files` pattern to support matching on uploads, thanks @ziima for input (#253) 45 | - Add `SetCookie` utility for easier mocking of response cookie headers (#254) 46 | 47 | ### Changed 48 | 49 | - Enhance documentation on iterable side effects (#255) 50 | - Enhance documentation on named routes and add tip about a catch-all route (#257) 51 | 52 | ## [0.20.2] - 2023-07-21 53 | 54 | ### Fixed 55 | 56 | - Better assertion output for `assert_all_called`, thanks @sileht (#224) 57 | - Support for quoted path pattern matching, thanks @alexdrydew for input (#240) 58 | 59 | ### Added 60 | 61 | - Enable content\_\_contains pattern, thanks @rjprins (#236) 62 | - Added initial `CONTRIBUTING.md`, thanks @morenoh149 (#238) 63 | 64 | ### Changed 65 | 66 | - Docs about retrieving mocked calls, thanks @tomhamiltonstubber (#230) 67 | - Docs about `Router.assert_all_called()`, thanks @BeyondEvil for input (#241) 68 | 69 | ## [0.20.1] - 2022-11-18 70 | 71 | ### Fixed 72 | 73 | - Support HTTPX 0.23.1, thanks @g-as for input (#223) 74 | 75 | ### Added 76 | 77 | - Officially support Python 3.11 (#223) 78 | - Run pre-commit hooks in CI workflow (#219) 79 | 80 | ### Changed 81 | 82 | - Bump autoflake, thanks @antonagestam (#220) 83 | 84 | ### Removed 85 | 86 | - Drop support for Python 3.6 (#218) 87 | 88 | ## [0.20.0] - 2022-09-16 89 | 90 | ### Changed 91 | 92 | - Type `Router.__getitem__` to not return optional routes, thanks @flaeppe (#216) 93 | - Change `Call.response` to raise instead of returning optional response (#217) 94 | - Change `CallList.last` to raise instead of return optional call (#217) 95 | - Type `M()` to not return optional pattern, by introducing a `Noop` pattern (#217) 96 | - Type `Route.pattern` to not be optional (#217) 97 | 98 | ### Fixed 99 | 100 | - Correct type hints for side effects (#217) 101 | 102 | ### Added 103 | 104 | - Runs `mypy` on both tests and respx (#217) 105 | - Added nox test session for python 3.11 (#217) 106 | - Added `Call.has_response` helper, now that `.response` raises (#217) 107 | 108 | ## [0.19.3] - 2022-09-14 109 | 110 | ### Fixed 111 | 112 | - Fix typing for Route modulos arg 113 | - Respect patterns with empty value when using equal lookup (#206) 114 | - Use pytest asyncio auto mode (#212) 115 | - Fix mock decorator to work together with pytest fixtures (#213) 116 | - Wrap pytest function correctly, i.e. don't hide real function name (#213) 117 | 118 | ### Changed 119 | 120 | - Enable mypy strict_optional (#201) 121 | 122 | ## [0.19.2] - 2022-02-03 123 | 124 | ### Fixed 125 | 126 | - Better cleanup before building egg, thanks @nebularazer (#198) 127 | 128 | ## [0.19.1] - 2022-01-10 129 | 130 | ### Fixed 131 | 132 | - Allow first path segments containing colons, thanks @hannseman. (#192) 133 | - Fix license classifier, thanks @shadchin (#195) 134 | - Fix typos, thanks @kianmeng (#194) 135 | 136 | ## [0.19.0] - 2021-11-15 137 | 138 | ### Fixed 139 | 140 | - Support HTTPX 0.21.0. (#189) 141 | - Use Session.notify when chaining nox sessions, thanks @flaeppe. (#188) 142 | - Add overloads to `MockRouter.__call__`, thanks @flaeppe. (#187) 143 | - Enhance AND pattern evaluation to fail fast. (#185) 144 | - Fix CallList assertion error message. (#178) 145 | 146 | ### Changed 147 | 148 | - Prevent method and url as lookups in HTTP method helpers, thanks @flaeppe. (#183) 149 | - Fail pattern match when JSON path not found. (#184) 150 | 151 | ## [0.18.2] - 2021-10-22 152 | 153 | ### Fixed 154 | 155 | - Include extensions when instantiating request in HTTPCoreMocker. (#176) 156 | 157 | ## [0.18.1] - 2021-10-20 158 | 159 | ### Fixed 160 | 161 | - Respect ordered param values. (#172) 162 | 163 | ### Changed 164 | 165 | - Raise custom error types for assertion checks. (#174) 166 | 167 | ## [0.18.0] - 2021-10-14 168 | 169 | ### Fixed 170 | 171 | - Downgrade `HTTPX` requirement to 0.20.0. (#170) 172 | 173 | ### Added 174 | 175 | - Add support for matching param with _ANY_ value. (#167) 176 | 177 | ## [0.18.0b0] - 2021-09-15 178 | 179 | ### Changed 180 | 181 | - Deprecate RESPX MockTransport in favour of HTTPX MockTransport. (#152) 182 | 183 | ### Fixed 184 | 185 | - Support `HTTPX` 1.0.0b0. (#164) 186 | - Allow tuples as params to align with httpx, thanks @shelbylsmith. (#151) 187 | - Fix xfail marked tests. (#153) 188 | - Only publish docs for upstream repo, thanks @hugovk. (#161) 189 | 190 | ### Added 191 | 192 | - Add optional route arg to side effects. (#158) 193 | 194 | ## [0.17.1] - 2021-06-05 195 | 196 | ### Added 197 | 198 | - Implement support for async side effects in router. (#147) 199 | - Support mocking responses using asgi/wsgi apps. (#146) 200 | - Added pytest fixture and configuration marker. (#150) 201 | 202 | ### Fixed 203 | 204 | - Typo in import from examples.md, thanks @shelbylsmith. (#148) 205 | - Fix pass-through test case. (#149) 206 | 207 | ## [0.17.0] - 2021-04-27 208 | 209 | ### Changed 210 | 211 | - Require `HTTPX` 0.18.0 and implement the new transport API. (PR #142) 212 | - Removed ASGI and WSGI transports from httpcore patch list. (PR #131) 213 | - Don't pre-read mocked async response streams. (PR #136) 214 | 215 | ### Fixed 216 | 217 | - Fixed syntax highlighting in docs, thanks @florimondmanca. (PR #134) 218 | - Type check `route.return_value`, thanks @tzing. (PR #133) 219 | - Fixed a typo in the docs, thanks @lewoudar. (PR #139) 220 | 221 | ### Added 222 | 223 | - Added support for adding/removing patch targets. (PR #131) 224 | - Added test session for python 3.10. (PR #140) 225 | - Added RESPX Mock Swallowtail to README. (PR #128) 226 | 227 | ## [0.16.3] - 2020-12-14 228 | 229 | ### Fixed 230 | 231 | - Fixed decorator `respx_mock` kwarg, mistreated as a `pytest` fixture. (PR #117) 232 | - Fixed `JSON` pattern sometimes causing a `JSONDecodeError`. (PR #124) 233 | 234 | ### Added 235 | 236 | - Snapshot and rollback of routes' pattern and name. (PR #120) 237 | - Internally extracted a `RouteList` from `Router`. (PR #120) 238 | - Auto registration of `Mocker` implementations and their `using` name. (PR #121) 239 | - Added `HTTPXMocker`, optionally patching `HTTPX`. (PR #122) 240 | 241 | ### Changed 242 | 243 | - Protected a routes' pattern to be modified. (PR #120) 244 | 245 | ## [0.16.2] - 2020-11-26 246 | 247 | ### Added 248 | 249 | - Easier support for using HTTPX MockTransport. (PR #118) 250 | - Support mixed case for `method__in` and `scheme__in` pattern lookups. (PR #113) 251 | 252 | ### Fixed 253 | 254 | - Handle missing path in URL pattern (PR #113) 255 | 256 | ### Changed 257 | 258 | - Refactored internal mocking vs `MockTransport`. (PR #112) 259 | 260 | ### Removed 261 | 262 | - Dropped raw request support when parsing patterns (PR #113) 263 | 264 | ## [0.16.1] - 2020-11-16 265 | 266 | ### Added 267 | 268 | - Extended `url` pattern with support for `HTTPX` proxy url format. (PR #110) 269 | - Extended `host` pattern with support for regex lookup. (PR #110) 270 | - Added `respx.request(...)`. (PR #111) 271 | 272 | ### Changed 273 | 274 | - Deprecated old `MockTransport` in favour of `respx.mock(...)`. (PR #109) 275 | - Wrapping actual `MockTransport` in `MockRouter`, instead of extending. (PR #109) 276 | - Extracted a `HTTPXMock`, for transport patching, from `MockRouter`. (PR #109) 277 | 278 | ## [0.16.0] - 2020-11-13 279 | 280 | One year since first release, yay! 281 | 282 | ### Removed 283 | 284 | - Dropped all deprecated APIs and models, see `0.15.0` Changed section. (PR #105) 285 | 286 | ### Added 287 | 288 | - Added support for content, data and json patterns. (PR #106) 289 | - Automatic pattern registration when subclassing Pattern. (PR #108) 290 | 291 | ### Fixed 292 | 293 | - Multiple snapshots to support nested mock routers. (PR #107) 294 | 295 | ## [0.15.1] - 2020-11-10 296 | 297 | ### Added 298 | 299 | - Snapshot routes and mocks when starting router, rollback when stopping. (PR #102) 300 | - Added support for base_url combined with pattern lookups. (PR #103) 301 | - Added support for patterns/lookups to the HTTP method helpers. (PR #104) 302 | 303 | ### Fixed 304 | 305 | - Fix to not clear routes added outside mock context when stopping router. (PR #102) 306 | 307 | ## [0.15.0] - 2020-11-09 308 | 309 | ### Added 310 | 311 | - Added `respx.route(...)` with enhanced request pattern matching. (PR #96) 312 | - Added support for AND/OR when request pattern matching. (PR #96) 313 | - Added support for adding responses to a route using % operator. (PR #96) 314 | - Added support for both `httpx.Response` and `MockResponse`. (PR #96) 315 | - Enhanced Route (RequestPattern) with `.respond(...)` response details. (PR #96) 316 | - Enhanced Route (RequestPattern) with `.pass_through()`. (PR #96) 317 | - Add support for using route as side effect decorator. (PR #98) 318 | - Add `headers` and `cookies` patterns. (PR #99) 319 | - Add `contains` and `in` lookups. (PR #99) 320 | - Introduced Route `.mock(...)` in favour of callbacks. (PR #101) 321 | - Introduced Route `.return_value` and `.side_effect` setters. (PR #101) 322 | 323 | ### Changed 324 | 325 | - Deprecated mixing of request pattern and response details in all API's. (PR #96) 326 | - Deprecated passing http method as arg in `respx.add` in favour of `method=`. (PR #96) 327 | - Deprecated `alias=...` in favour of `name=...` when adding routes. (PR #96) 328 | - Deprecated `respx.aliases` in favour of `respx.routes`. (PR #96) 329 | - Deprecated `RequestPattern` in favour of `Route`. (PR #96) 330 | - Deprecated `ResponseTemplate` in favour of `MockResponse`. (PR #96) 331 | - Deprecated `pass_through=` in HTTP method API's (PR #96) 332 | - Deprecated `response` arg in side effects (callbacks). (PR #97) 333 | - Stacked responses are now recorded on same route calls. (PR #96) 334 | - Pass-through routes no longer capture real response in call stats. (PR #97) 335 | - Stacked responses no longer keeps and repeats last response. (PR #101) 336 | 337 | ### Removed 338 | 339 | - Removed support for regex `base_url`. (PR #96) 340 | - Dropped support for `async` side effects (callbacks). (PR #97) 341 | - Dropped support for mixing side effect (callback) and response details. (PR #97) 342 | 343 | ## [0.14.0] - 2020-10-15 344 | 345 | ### Added 346 | 347 | - Added `text`, `html` and `json` content shorthands to ResponseTemplate. (PR #82) 348 | - Added `text`, `html` and `json` content shorthands to high level API. (PR #93) 349 | - Added support to set `http_version` for a mocked response. (PR #82) 350 | - Added support for mocking by lowercase http methods, thanks @lbillinghamtn. (PR #80) 351 | - Added query `params` to align with HTTPX API, thanks @jocke-l. (PR #81) 352 | - Easier API to get request/response from call stats, thanks @SlavaSkvortsov. (PR #85) 353 | - Enhanced test to verify better content encoding by HTTPX. (PR #78) 354 | - Added Python 3.9 to supported versions and test suite, thanks @jairhenrique. (PR #89) 355 | 356 | ### Changed 357 | 358 | - `ResponseTemplate.content` as proper getter, i.e. no resolve/encode to bytes. (PR #82) 359 | - Enhanced headers by using HTTPX Response when encoding raw responses. (PR #82) 360 | - Deprecated `respx.stats` in favour of `respx.calls`, thanks @SlavaSkvortsov. (PR #92) 361 | 362 | ### Fixed 363 | 364 | - Recorded requests in call stats are pre-read like the responses. (PR #86) 365 | - Postponed request decoding for enhanced performance. (PR #91) 366 | - Lazy call history for enhanced performance, thanks @SlavaSkvortsov. (PR #92) 367 | 368 | ### Removed 369 | 370 | - Removed auto setting the `Content-Type: text/plain` header. (PR #82) 371 | 372 | ## [0.13.0] - 2020-09-30 373 | 374 | ### Fixed 375 | 376 | - Fixed support for `HTTPX` 0.15. (PR #77) 377 | 378 | ### Added 379 | 380 | - Added global `respx.pop` api, thanks @paulineribeyre. (PR #72) 381 | 382 | ### Removed 383 | 384 | - Dropped deprecated `HTTPXMock` in favour of `MockTransport`. 385 | - Dropped deprecated `respx.request` in favour of `respx.add`. 386 | - Removed `HTTPX` max version requirement in setup.py. 387 | 388 | ## [0.12.1] - 2020-08-21 389 | 390 | ### Fixed 391 | 392 | - Fixed non-iterable pass-through responses. (PR #68) 393 | 394 | ## [0.12.0] - 2020-08-17 395 | 396 | ### Changed 397 | 398 | - Dropped no longer needed `asynctest` dependency, in favour of built-in mock. (PR #69) 399 | 400 | ## [0.11.3] - 2020-08-13 401 | 402 | ### Fixed 403 | 404 | - Fixed support for `HTTPX` 0.14.0. (PR #45) 405 | 406 | ## [0.11.2] - 2020-06-25 407 | 408 | ### Added 409 | 410 | - Added support for pop'ing a request pattern by alias, thanks @radeklat. (PR #60) 411 | 412 | ## [0.11.1] - 2020-06-01 413 | 414 | ### Fixed 415 | 416 | - Fixed mocking `HTTPX` clients instantiated with proxies. (PR #58) 417 | - Fixed matching URL patterns with missing path. (PR #59) 418 | 419 | ## [0.11.0] - 2020-05-29 420 | 421 | ### Fixed 422 | 423 | - Fixed support for `HTTPX` 0.13. (PR #57) 424 | 425 | ### Added 426 | 427 | - Added support for mocking out `HTTP Core`. 428 | - Added support for using mock transports with `HTTPX` clients without patching. 429 | - Include LICENSE.md in source distribution, thanks @synapticarbors. 430 | 431 | ### Changed 432 | 433 | - Renamed passed mock to decorated functions from `httpx_mock` to `respx_mock`. 434 | - Renamed `HTTPXMock` to `MockTransport`, but kept a deprecated `HTTPXMock` subclass. 435 | - Deprecated `respx.request()` in favour of `respx.add()`. 436 | 437 | ## [0.10.1] - 2020-03-11 438 | 439 | ### Fixed 440 | 441 | - Fixed support for `HTTPX` 0.12.0. (PR #45) 442 | 443 | ## [0.10.0] - 2020-01-30 444 | 445 | ### Changed 446 | 447 | - Refactored high level and internal api for better editor autocompletion. (PR #44) 448 | 449 | ## [0.9.0] - 2020-01-22 450 | 451 | ### Fixed 452 | 453 | - Fixed usage of nested or parallel mock instances. (PR #39) 454 | 455 | ## [0.8.3] - 2020-01-10 456 | 457 | ### Fixed 458 | 459 | - Fixed support for `HTTPX` 0.11.0 sync api. (PR #38) 460 | 461 | ## [0.8.2] - 2020-01-07 462 | 463 | ### Fixed 464 | 465 | - Renamed refactored httpx internals. (PR #37) 466 | 467 | ## [0.8.1] - 2019-12-09 468 | 469 | ### Added 470 | 471 | - Added support for configuring patterns `base_url`. (PR #34) 472 | - Added manifest and `py.typed` files. 473 | 474 | ### Fixed 475 | 476 | - Fixed support for `HTTPX` 0.9.3 refactorizations. (PR #35) 477 | 478 | ## [0.8] - 2019-11-27 479 | 480 | ### Added 481 | 482 | - Added documentation built with `mkdocs`. (PR #30) 483 | 484 | ### Changed 485 | 486 | - Dropped sync support and now requires `HTTPX` version 0.8+. (PR #32) 487 | - Renamed `respx.mock` module to `respx.api`. (PR #29) 488 | - Refactored tests- and checks-runner to `nox`. (PR #31) 489 | 490 | ## [0.7.4] - 2019-11-24 491 | 492 | ### Added 493 | 494 | - Allowing assertions to be configured through decorator and context manager. (PR #28) 495 | 496 | ## [0.7.3] - 2019-11-21 497 | 498 | ### Added 499 | 500 | - Allows `mock` decorator to be used as sync or async context manager. (PR #27) 501 | 502 | ## [0.7.2] - 2019-11-21 503 | 504 | ### Added 505 | 506 | - Added `stats` to high level API and patterns, along with `call_count`. (PR #25) 507 | 508 | ### Fixed 509 | 510 | - Allowing headers to be modified within a pattern match callback. (PR #26) 511 | 512 | ## [0.7.1] - 2019-11-20 513 | 514 | ### Fixed 515 | 516 | - Fixed responses in call stats when using synchronous `HTTPX` client. (PR #23) 517 | 518 | ## [0.7] - 2019-11-19 519 | 520 | ### Added 521 | 522 | - Added support for `pass_through` patterns. (PR #20) 523 | - Added `assert_all_mocked` feature and setting. (PR #21) 524 | 525 | ### Changed 526 | 527 | - Requires all `HTTPX` requests to be mocked. 528 | 529 | ## [0.6] - 2019-11-18 530 | 531 | ### Changed 532 | 533 | - Renamed `activate` decorator to `mock`. (PR #15) 534 | 535 | ## [0.5] - 2019-11-18 536 | 537 | ### Added 538 | 539 | - Added `assert_all_called` feature and setting. (PR #14) 540 | 541 | ### Changed 542 | 543 | - Clears call stats when exiting decorator. 544 | 545 | ## [0.4] - 2019-11-16 546 | 547 | ### Changed 548 | 549 | - Renamed python package to `respx`. (PR #12) 550 | - Renamed `add()` to `request()` and added HTTP method shorthands. (PR #13) 551 | 552 | ## [0.3.1] - 2019-11-16 553 | 554 | ### Changed 555 | 556 | - Renamed PyPI package to `respx`. 557 | 558 | ## [0.3] - 2019-11-15 559 | 560 | ### Added 561 | 562 | - Exposes `responsex` high level API along with a `activate` decorator. (PR #5) 563 | - Added support for custom pattern match callback function. (PR #7) 564 | - Added support for repeated patterns. (PR #8) 565 | 566 | ## [0.2] - 2019-11-14 567 | 568 | ### Added 569 | 570 | - Added support for any `HTTPX` concurrency backend. 571 | 572 | ## [0.1] - 2019-11-13 573 | 574 | ### Added 575 | 576 | - Initial POC. 577 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to RESPX 2 | 3 | As an open source project, RESPX welcomes contributions of many forms. 4 | 5 | Examples of contributions include: 6 | 7 | - Code patches 8 | - Documentation improvements 9 | - Bug reports and patch reviews 10 | 11 | ## Running Tests 12 | 13 | Tests reside in the `tests/` directory. You can run tests with the 14 | [Task](https://taskfile.dev/installation/) tool from the root of the project. 15 | 16 | - `task test` 17 | 18 | ## Linting 19 | 20 | Any contributions should pass the linters setup in this project. 21 | 22 | - `task lint` 23 | 24 | Linters will also be run through github CI on your PR automatically. 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, 5 Monkeys Agency AB 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-exclude .github * 2 | recursive-exclude docs * 3 | recursive-exclude tests * 4 | exclude *.yaml 5 | exclude *.xml 6 | exclude flake.* 7 | exclude noxfile.py 8 | exclude CONTRIBUTING.md 9 | include README.md 10 | include CHANGELOG.md 11 | include LICENSE.md 12 | include respx/py.typed 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | RESPX 3 |

4 |

5 | RESPX - Mock HTTPX with awesome request patterns and response side effects. 6 |

7 | 8 | --- 9 | 10 | [![tests](https://img.shields.io/github/actions/workflow/status/lundberg/respx/test.yml?branch=master&label=tests&logo=github&logoColor=white&style=for-the-badge)](https://github.com/lundberg/respx/actions/workflows/test.yml) 11 | [![codecov](https://img.shields.io/codecov/c/github/lundberg/respx?logo=codecov&logoColor=white&style=for-the-badge)](https://codecov.io/gh/lundberg/respx) 12 | [![PyPi Version](https://img.shields.io/pypi/v/respx?logo=pypi&logoColor=white&style=for-the-badge)](https://pypi.org/project/respx/) 13 | [![Python Versions](https://img.shields.io/pypi/pyversions/respx?logo=python&logoColor=white&style=for-the-badge)](https://pypi.org/project/respx/) 14 | 15 | ## Documentation 16 | 17 | Full documentation is available at 18 | [lundberg.github.io/respx](https://lundberg.github.io/respx/) 19 | 20 | ## QuickStart 21 | 22 | RESPX is a simple, _yet powerful_, utility for mocking out the 23 | [HTTPX](https://www.python-httpx.org/), _and 24 | [HTTP Core](https://www.encode.io/httpcore/)_, libraries. 25 | 26 | Start by [patching](https://lundberg.github.io/respx/guide/#mock-httpx) `HTTPX`, using 27 | `respx.mock`, then add request 28 | [routes](https://lundberg.github.io/respx/guide/#routing-requests) to mock 29 | [responses](https://lundberg.github.io/respx/guide/#mocking-responses). 30 | 31 | ```python 32 | import httpx 33 | import respx 34 | 35 | from httpx import Response 36 | 37 | 38 | @respx.mock 39 | def test_example(): 40 | my_route = respx.get("https://example.org/").mock(return_value=Response(204)) 41 | response = httpx.get("https://example.org/") 42 | assert my_route.called 43 | assert response.status_code == 204 44 | ``` 45 | 46 | > Read the [User Guide](https://lundberg.github.io/respx/guide/) for a complete 47 | > walk-through. 48 | 49 | ### pytest + httpx 50 | 51 | For a neater `pytest` experience, RESPX includes a `respx_mock` _fixture_ for easy 52 | `HTTPX` mocking, along with an optional `respx` _marker_ to fine-tune the mock 53 | [settings](https://lundberg.github.io/respx/api/#configuration). 54 | 55 | ```python 56 | import httpx 57 | import pytest 58 | 59 | 60 | def test_default(respx_mock): 61 | respx_mock.get("https://foo.bar/").mock(return_value=httpx.Response(204)) 62 | response = httpx.get("https://foo.bar/") 63 | assert response.status_code == 204 64 | 65 | 66 | @pytest.mark.respx(base_url="https://foo.bar") 67 | def test_with_marker(respx_mock): 68 | respx_mock.get("/baz/").mock(return_value=httpx.Response(204)) 69 | response = httpx.get("https://foo.bar/baz/") 70 | assert response.status_code == 204 71 | ``` 72 | 73 | ## Installation 74 | 75 | Install with pip: 76 | 77 | ```console 78 | $ pip install respx 79 | ``` 80 | 81 | Requires Python 3.8+ and HTTPX 0.25+. See 82 | [Changelog](https://github.com/lundberg/respx/blob/master/CHANGELOG.md) for older HTTPX 83 | compatibility. 84 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | tasks: 4 | default: 5 | cmds: [task: all] 6 | 7 | all: 8 | desc: Run test suite, mypy & linting 9 | label: all -- [nox options] 10 | silent: true 11 | deps: [tools] 12 | cmds: 13 | - .venv/bin/nox -k "test + mypy" {{.CLI_ARGS | default "-R"}} 14 | - task: lint 15 | 16 | test: 17 | desc: Run test suite against latest python 18 | label: test -- [pytest options] 19 | silent: true 20 | deps: [tools] 21 | cmds: [".venv/bin/nox -R -s test-3.11 -- {{.CLI_ARGS}}"] 22 | 23 | mypy: 24 | desc: Statically type check python files 25 | silent: true 26 | deps: [tools] 27 | cmds: [.venv/bin/nox -R -s mypy] 28 | 29 | lint: 30 | desc: Lint project files 31 | silent: true 32 | deps: [tools] 33 | cmds: [.venv/bin/pre-commit run --all-files] 34 | 35 | docs: 36 | desc: Start docs server, in watch mode 37 | silent: true 38 | deps: [tools] 39 | cmds: [.venv/bin/nox -R -s docs -- serve] 40 | 41 | reset: 42 | desc: Delete environment and artifacts 43 | silent: true 44 | cmds: 45 | - echo Deleting environment and artifacts ... 46 | - rm -rf \ 47 | .venv .nox .mypy_cache .pytest_cache respx.egg-info .coverage coverage.xml 48 | 49 | tools: 50 | internal: true 51 | silent: true 52 | run: once 53 | deps: [venv] 54 | cmds: [.venv/bin/python -m pip install nox pre-commit] 55 | status: 56 | - test -f .venv/bin/nox 57 | - test -f .venv/bin/pre-commit 58 | 59 | venv: 60 | internal: true 61 | silent: true 62 | run: once 63 | cmds: [python -m venv --copies --upgrade-deps .venv > /dev/null] 64 | status: [test -d .venv] 65 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## Router 4 | 5 | ### Configuration 6 | 7 | Creates a mock `Router` instance, ready to be used as decorator/manager for activation. 8 | 9 | > respx.mock(assert_all_mocked=True, *assert_all_called=True, base_url=None*) 10 | > 11 | > **Parameters:** 12 | > 13 | > * **assert_all_mocked** - *(optional) bool - default: `True`* 14 | > Asserts that all sent and captured `HTTPX` requests are routed and mocked. 15 | > If disabled, all non-routed requests will be auto mocked with status code `200`. 16 | > * **assert_all_called** - *(optional) bool - default: `True`* 17 | > Asserts that all added and mocked routes were called when exiting context. 18 | > * **base_url** - *(optional) str* 19 | > Base URL to match, on top of each route specific pattern *and/or* side effect. 20 | > 21 | > **Returns:** `Router` 22 | 23 | !!! note "NOTE" 24 | When using the *default* mock router `respx.mock`, *without settings*, `assert_all_called` is **disabled**. 25 | 26 | !!! tip "pytest" 27 | Use the `@pytest.mark.respx(...)` marker with these parameters to configure the `respx_mock` [pytest fixture](examples.md#built-in-marker). 28 | 29 | 30 | ### .route() 31 | 32 | Adds a new, *optionally named*, `Route` with given [patterns](#patterns) *and/or* [lookups](#lookups) combined, using the [AND](#and) operator. 33 | 34 | > respx.route(*\*patterns, name=None, \*\*lookups*) 35 | > 36 | > **Parameters:** 37 | > 38 | > * **patterns** - *(optional) args* 39 | > One or more [pattern](#patterns) objects. 40 | > * **lookups** - *(optional) kwargs* 41 | > One or more [pattern](#patterns) keyword [lookups](#lookups), given as `__=value`. 42 | > * **name** - *(optional) str* 43 | > Name this route. 44 | > 45 | > **Returns:** `Route` 46 | 47 | ### .get(), .post(), ... 48 | 49 | HTTP method helpers to add routes, mimicking the [HTTPX Helper Functions](https://www.python-httpx.org/api/#helper-functions). 50 | 51 | > respx.get(*url, name=None, \*\*lookups*) 52 | 53 | > respx.options(...) 54 | 55 | > respx.head(...) 56 | 57 | > respx.post(...) 58 | 59 | > respx.put(...) 60 | 61 | > respx.patch(...) 62 | 63 | > respx.delete(...) 64 | > 65 | > **Parameters:** 66 | > 67 | > * **url** - *(optional) str | compiled regex | tuple (httpcore) | httpx.URL* 68 | > Request URL to match, *full or partial*, turned into a [URL](#url) pattern. 69 | > * **name** - *(optional) str* 70 | > Name this route. 71 | > * **lookups** - *(optional) kwargs* 72 | > One or more [pattern](#patterns) keyword [lookups](#lookups), given as `__=value`. 73 | > 74 | > **Returns:** `Route` 75 | ``` python 76 | respx.get("https://example.org/", params={"foo": "bar"}, ...) 77 | ``` 78 | 79 | ### .request() 80 | 81 | > respx.request(*method, url, name=None, \*\*lookups*) 82 | > 83 | > **Parameters:** 84 | > 85 | > * **method** - *str* 86 | > Request HTTP method to match. 87 | > * **url** - *(optional) str | compiled regex | tuple (httpcore) | httpx.URL* 88 | > Request URL to match, *full or partial*, turned into a [URL](#url) pattern. 89 | > * **name** - *(optional) str* 90 | > Name this route. 91 | > * **lookups** - *(optional) kwargs* 92 | > One or more [pattern](#patterns) keyword [lookups](#lookups), given as `__=value`. 93 | > 94 | > **Returns:** `Route` 95 | ``` python 96 | respx.request("GET", "https://example.org/", params={"foo": "bar"}, ...) 97 | ``` 98 | 99 | --- 100 | 101 | ## Route 102 | 103 | ### .mock() 104 | 105 | Mock a route's response or side effect. 106 | 107 | > route.mock(*return_value=None, side_effect=None*) 108 | > 109 | > **Parameters:** 110 | > 111 | > * **return_value** - *(optional) [Response](#response)* 112 | > HTTPX Response to mock and return. 113 | > * **side_effect** - *(optional) Callable | Exception | Iterable of httpx.Response/Exception* 114 | > [Side effect](guide.md#mock-with-a-side-effect) to call, exception to raise or stacked responses to respond with in order. 115 | > 116 | > **Returns:** `Route` 117 | 118 | ### .return_value 119 | 120 | Setter for the `HTTPX` [Response](#response) to return. 121 | 122 | > route.**return_value** = Response(204) 123 | 124 | ### .side_effect 125 | 126 | Setter for the [side effect](guide.md#mock-with-a-side-effect) to trigger. 127 | 128 | > route.**side_effect** = ... 129 | > 130 | > See [route.mock()](#mock) for valid side effect types. 131 | 132 | ### .respond() 133 | 134 | Shortcut for creating and mocking a `HTTPX` [Response](#response). 135 | 136 | > route.respond(*status_code=200, headers=None, cookies=None, content=None, text=None, html=None, json=None, stream=None, content_type=None*) 137 | > 138 | > **Parameters:** 139 | > 140 | > * **status_code** - *(optional) int - default: `200`* 141 | > Response status code to mock. 142 | > * **headers** - *(optional) dict | Sequence[tuple[str, str]]* 143 | > Response headers to mock. 144 | > * **cookies** - *(optional) dict | Sequence[tuple[str, str]] | Sequence[SetCookie]* 145 | > Response cookies to mock as `Set-Cookie` headers. See [SetCookie](#setcookie). 146 | > * **content** - *(optional) bytes | str | Iterable[bytes]* 147 | > Response raw content to mock. 148 | > * **text** - *(optional) str* 149 | > Response *text* content to mock, with automatic content-type header added. 150 | > * **html** - *(optional) str* 151 | > Response *HTML* content to mock, with automatic content-type header added. 152 | > * **json** - *(optional) str | list | dict* 153 | > Response *JSON* content to mock, with automatic content-type header added. 154 | > * **stream** - *(optional) Iterable[bytes]* 155 | > Response *stream* to mock. 156 | > * **content_type** - *(optional) str* 157 | > Response `Content-Type` header to mock. 158 | > 159 | > **Returns:** `Route` 160 | 161 | ### .pass_through() 162 | 163 | > route.pass_through(*value=True*) 164 | > 165 | > **Parameters:** 166 | > 167 | > * **value** - *(optional) bool - default: `True`* 168 | > Mark route to pass through, sending matched requests to real server, *e.g. don't mock*. 169 | > 170 | > **Returns:** `Route` 171 | 172 | --- 173 | 174 | ## Response 175 | 176 | !!! note "NOTE" 177 | This is a partial reference for how to the instantiate the **HTTPX** `Response`class, e.g. *not* a RESPX class. 178 | 179 | > httpx.Response(*status_code, headers=None, content=None, text=None, html=None, json=None, stream=None*) 180 | > 181 | > **Parameters:** 182 | > 183 | > * **status_code** - *int* 184 | > HTTP status code. 185 | > * **headers** - *(optional) dict | httpx.Headers* 186 | > HTTP headers. 187 | > * **content** - *(optional) bytes | str | Iterable[bytes]* 188 | > Raw content. 189 | > * **text** - *(optional) str* 190 | > Text content, with automatic content-type header added. 191 | > * **html** - *(optional) str* 192 | > HTML content, with automatic content-type header added. 193 | > * **json** - *(optional) str | list | dict* 194 | > JSON content, with automatic content-type header added. 195 | > * **stream** - *(optional) Iterable[bytes]* 196 | > Content *stream*. 197 | 198 | !!! tip "Cookies" 199 | Use [respx.SetCookie(...)](#setcookie) to produce `Set-Cookie` headers. 200 | 201 | --- 202 | 203 | ## SetCookie 204 | 205 | A utility to render a `("Set-Cookie", )` tuple. See route [respond](#respond) shortcut for alternative use. 206 | 207 | > respx.SetCookie(*name, value, path=None, domain=None, expires=None, max_age=None, http_only=False, same_site=None, secure=False, partitioned=False*) 208 | 209 | ``` python 210 | import respx 211 | respx.post("https://example.org/").mock( 212 | return_value=httpx.Response(200, headers=[SetCookie("foo", "bar")]) 213 | ) 214 | ``` 215 | 216 | --- 217 | 218 | ## Patterns 219 | 220 | ### M() 221 | 222 | Creates a reusable pattern, combining multiple arguments using the [AND](#and) operator. 223 | 224 | > M(*\*patterns, \*\*lookups*) 225 | > 226 | > **Parameters:** 227 | > 228 | > * **patterns** - *(optional) args* 229 | > One or more [pattern](#patterns) objects. 230 | > * **lookups** - *(optional) kwargs* 231 | > One or more [pattern](#patterns) keyword [lookups](#lookups), given as `__=value`. 232 | > 233 | > **Returns:** `Pattern` 234 | ``` python 235 | import respx 236 | from respx.patterns import M 237 | pattern = M(host="example.org") 238 | respx.route(pattern) 239 | ``` 240 | > See [operators](#operators) for advanced usage. 241 | 242 | 243 | 244 | ### Method 245 | Matches request *HTTP method*, using [eq](#eq) as default lookup. 246 | > Key: `method` 247 | > Lookups: [eq](#eq), [in](#in) 248 | ``` python 249 | respx.route(method="GET") 250 | respx.route(method__in=["PUT", "PATCH"]) 251 | ``` 252 | 253 | ### Scheme 254 | Matches request *URL scheme*, using [eq](#eq) as default lookup. 255 | > Key: `scheme` 256 | > Lookups: [eq](#eq), [in](#in) 257 | ``` python 258 | respx.route(scheme="https") 259 | respx.route(scheme__in=["http", "https"]) 260 | ``` 261 | 262 | ### Host 263 | Matches request *URL host*, using [eq](#eq) as default lookup. 264 | > Key: `host` 265 | > Lookups: [eq](#eq), [regex](#regex), [in](#in) 266 | ``` python 267 | respx.route(host="example.org") 268 | respx.route(host__regex=r"example\.(org|com)") 269 | respx.route(host__in=["example.org", "example.com"]) 270 | ``` 271 | 272 | ### Port 273 | Matches request *URL port*, using [eq](#eq) as default lookup. 274 | > Key: `port` 275 | > Lookups: [eq](#eq), [in](#in) 276 | 277 | ``` python 278 | respx.route(port=8000) 279 | respx.route(port__in=[2375, 2376]) 280 | ``` 281 | 282 | ### Path 283 | Matches request *URL path*, using [eq](#eq) as default lookup. 284 | > Key: `path` 285 | > Lookups: [eq](#eq), [regex](#regex), [startswith](#startswith), [in](#in) 286 | ``` python 287 | respx.route(path="/api/foobar/") 288 | respx.route(path__regex=r"^/api/(?P\w+)/") 289 | respx.route(path__startswith="/api/") 290 | respx.route(path__in=["/api/v1/foo/", "/api/v2/foo/"]) 291 | ``` 292 | 293 | ### Params 294 | Matches request *URL query params*, using [contains](#contains) as default lookup. 295 | > Key: `params` 296 | > Lookups: [contains](#contains), [eq](#eq) 297 | ``` python 298 | respx.route(params={"foo": "bar", "ham": "spam"}) 299 | respx.route(params=[("foo", "bar"), ("ham", "spam")]) 300 | respx.route(params=(("foo", "bar"), ("ham", "spam"))) 301 | respx.route(params="foo=bar&ham=spam") 302 | ``` 303 | 304 | !!! note "NOTE" 305 | A request querystring with multiple parameters of the same name is treated as an ordered list when matching. 306 | !!! tip "ANY value" 307 | Use `mock.ANY` as value to only match on parameter presence, e.g. `respx.route(params={"foo": ANY})`. 308 | 309 | ### URL 310 | Matches request *URL*. 311 | 312 | When no *lookup* is given, `url` works as a *shorthand* pattern, combining individual request *URL* parts, using the [AND](#and) operator. 313 | > Key: `url` 314 | > Lookups: [eq](#eq), [regex](#regex), [startswith](#startswith) 315 | ``` python 316 | respx.get("//example.org/foo/") # == M(host="example.org", path="/foo/") 317 | respx.get(url__eq="https://example.org:8080/foobar/?ham=spam") 318 | respx.get(url__regex=r"https://example.org/(?P\w+)/") 319 | respx.get(url__startswith="https://example.org/api/") 320 | respx.get("all://*.example.org/foo/") 321 | ``` 322 | 323 | ### Content 324 | Matches request raw *content*, using [eq](#eq) as default lookup. 325 | > Key: `content` 326 | > Lookups: [eq](#eq), [contains](#contains) 327 | ``` python 328 | respx.post("https://example.org/", content="foobar") 329 | respx.post("https://example.org/", content=b"foobar") 330 | respx.post("https://example.org/", content__contains="bar") 331 | ``` 332 | 333 | ### Data 334 | Matches request *form data*, excluding files, using [eq](#eq) as default lookup. 335 | > Key: `data` 336 | > Lookups: [eq](#eq), [contains](#contains) 337 | ``` python 338 | respx.post("https://example.org/", data={"foo": "bar"}) 339 | ``` 340 | 341 | ### Files 342 | Matches files within request *form data*, using [contains](#contains) as default lookup. 343 | > Key: `files` 344 | > Lookups: [contains](#contains), [eq](#eq) 345 | ``` python 346 | respx.post("https://example.org/", files={"some_file": b"..."}) 347 | respx.post("https://example.org/", files={"some_file": ANY}) 348 | respx.post("https://example.org/", files={"some_file": ("filename.txt", b"...")}) 349 | respx.post("https://example.org/", files={"some_file": ("filename.txt", ANY)}) 350 | ``` 351 | 352 | ### JSON 353 | Matches request *json* content, using [eq](#eq) as default lookup. 354 | > Key: `json` 355 | > Lookups: [eq](#eq) 356 | ``` python 357 | respx.post("https://example.org/", json={"foo": "bar"}) 358 | ``` 359 | The `json` pattern also supports path traversing, *i.e.* `json__=`. 360 | ``` python 361 | respx.post("https://example.org/", json__foobar__0__ham="spam") 362 | httpx.post("https://example.org/", json={"foobar": [{"ham": "spam"}]}) 363 | ``` 364 | 365 | ### Headers 366 | Matches request *headers*, using [contains](#contains) as default lookup. 367 | > Key: `headers` 368 | > Lookups: [contains](#contains), [eq](#eq) 369 | ``` python 370 | respx.route(headers={"foo": "bar", "ham": "spam"}) 371 | respx.route(headers=[("foo", "bar"), ("ham", "spam")]) 372 | ``` 373 | 374 | ### Cookies 375 | Matches request *cookie header*, using [contains](#contains) as default lookup. 376 | > Key: `cookies` 377 | > Lookups: [contains](#contains), [eq](#eq) 378 | ``` python 379 | respx.route(cookies={"foo": "bar", "ham": "spam"}) 380 | respx.route(cookies=[("foo", "bar"), ("ham", "spam")]) 381 | ``` 382 | 383 | ## Lookups 384 | 385 | ### eq 386 | 387 | ``` python 388 | M(path="/foo/bar/") 389 | M(path__eq="/foo/bar/") 390 | ``` 391 | 392 | ### contains 393 | Case-sensitive containment test. 394 | ``` python 395 | M(params__contains={"id": "123"}) 396 | ``` 397 | 398 | ### in 399 | Case-sensitive within test. 400 | ``` python 401 | M(method__in=["PUT", "PATCH"]) 402 | ``` 403 | 404 | ### regex 405 | ``` python 406 | M(path__regex=r"^/api/(?P\w+)/") 407 | ``` 408 | 409 | ### startswith 410 | Case-sensitive starts-with. 411 | ``` python 412 | M(path__startswith="/api/") 413 | ``` 414 | 415 | ## Operators 416 | 417 | Patterns can be combined using bitwise operators, creating new patterns. 418 | 419 | ### AND (&) 420 | Combines two `Pattern`s using `and` operator. 421 | ``` python 422 | M(scheme="http") & M(host="example.org") 423 | ``` 424 | 425 | ### OR (&) 426 | Combines two `Pattern`s using `or` operator. 427 | ``` python 428 | M(method="PUT") | M(method="PATCH") 429 | ``` 430 | 431 | ### INVERT (~) 432 | Inverts a `Pattern` match. 433 | ``` python 434 | ~M(params={"foo": "bar"}) 435 | ``` 436 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Test Case Examples 2 | 3 | Here's some test case examples, not exactly *how-to*, but to be inspired from. 4 | 5 | ## pytest 6 | 7 | ### Built-in Fixture 8 | 9 | RESPX includes the `respx_mock` pytest httpx *fixture*. 10 | 11 | ``` python 12 | import httpx 13 | 14 | 15 | def test_fixture(respx_mock): 16 | respx_mock.get("https://foo.bar/").mock(return_value=httpx.Response(204)) 17 | response = httpx.get("https://foo.bar/") 18 | assert response.status_code == 204 19 | ``` 20 | 21 | ### Built-in Marker 22 | 23 | To configure the `respx_mock` fixture, use the `respx` *marker*. 24 | 25 | ``` python 26 | import httpx 27 | import pytest 28 | 29 | 30 | @pytest.mark.respx(base_url="https://foo.bar") 31 | def test_configured_fixture(respx_mock): 32 | respx_mock.get("/baz/").mock(return_value=httpx.Response(204)) 33 | response = httpx.get("https://foo.bar/baz/") 34 | assert response.status_code == 204 35 | ``` 36 | 37 | > See router [configuration](api.md#configuration) reference for more details. 38 | 39 | 40 | ### Custom Fixtures 41 | ``` python 42 | # conftest.py 43 | import pytest 44 | import respx 45 | 46 | from httpx import Response 47 | 48 | 49 | @pytest.fixture 50 | def mocked_api(): 51 | with respx.mock( 52 | base_url="https://foo.bar", assert_all_called=False 53 | ) as respx_mock: 54 | users_route = respx_mock.get("/users/", name="list_users") 55 | users_route.return_value = Response(200, json=[]) 56 | ... 57 | yield respx_mock 58 | ``` 59 | 60 | ``` python 61 | # test_api.py 62 | import httpx 63 | 64 | 65 | def test_list_users(mocked_api): 66 | response = httpx.get("https://foo.bar/users/") 67 | assert response.status_code == 200 68 | assert response.json() == [] 69 | assert mocked_api["list_users"].called 70 | ``` 71 | 72 | **Session Scoped Fixtures** 73 | 74 | If a session scoped RESPX fixture is used in an async context, you also need to broaden the `pytest-asyncio` 75 | [event_loop](https://github.com/pytest-dev/pytest-asyncio#event_loop) fixture. 76 | You can use the `session_event_loop` utility for this. 77 | 78 | ``` python 79 | # conftest.py 80 | import pytest 81 | import respx 82 | 83 | from respx.fixtures import session_event_loop as event_loop # noqa: F401 84 | 85 | 86 | @pytest.fixture(scope="session") 87 | async def mocked_api(event_loop): # noqa: F811 88 | async with respx.mock(base_url="https://foo.bar") as respx_mock: 89 | ... 90 | yield respx_mock 91 | ``` 92 | 93 | ### Async Test Cases 94 | ``` python 95 | import httpx 96 | import respx 97 | 98 | 99 | @respx.mock 100 | async def test_async_decorator(): 101 | async with httpx.AsyncClient() as client: 102 | route = respx.get("https://example.org/") 103 | response = await client.get("https://example.org/") 104 | assert route.called 105 | assert response.status_code == 200 106 | 107 | 108 | async def test_async_ctx_manager(): 109 | async with respx.mock: 110 | async with httpx.AsyncClient() as client: 111 | route = respx.get("https://example.org/") 112 | response = await client.get("https://example.org/") 113 | assert route.called 114 | assert response.status_code == 200 115 | ``` 116 | 117 | 118 | ## unittest 119 | 120 | ### Regular Decoration 121 | 122 | ``` python 123 | # test_api.py 124 | import httpx 125 | import respx 126 | import unittest 127 | 128 | 129 | class APITestCase(unittest.TestCase): 130 | @respx.mock 131 | def test_some_endpoint(self): 132 | respx.get("https://example.org/") % 202 133 | response = httpx.get("https://example.org/") 134 | self.assertEqual(response.status_code, 202) 135 | ``` 136 | 137 | ### Reuse SetUp & TearDown 138 | 139 | ``` python 140 | # testcases.py 141 | import respx 142 | 143 | from httpx import Response 144 | 145 | 146 | class MockedAPIMixin: 147 | @classmethod 148 | def setUpClass(cls): 149 | cls.mocked_api = respx.mock( 150 | base_url="https://foo.bar", assert_all_called=False 151 | ) 152 | users_route = cls.mocked_api.get("/users/", name="list_users") 153 | users_route.return_value = Response(200, json=[]) 154 | ... 155 | 156 | def setUp(self): 157 | self.mocked_api.start() 158 | self.addCleanup(self.mocked_api.stop) 159 | ``` 160 | ``` python 161 | # test_api.py 162 | import httpx 163 | import unittest 164 | 165 | from .testcases import MockedAPIMixin 166 | 167 | 168 | class APITestCase(MockedAPIMixin, unittest.TestCase): 169 | def test_list_users(self): 170 | response = httpx.get("https://foo.bar/users/") 171 | self.assertEqual(response.status_code, 200) 172 | self.assertListEqual(response.json(), []) 173 | self.assertTrue(self.mocked_api["list_users"].called) 174 | ``` 175 | 176 | ### Async Test Cases 177 | ``` python 178 | import asynctest 179 | import httpx 180 | import respx 181 | 182 | 183 | class MyTestCase(asynctest.TestCase): 184 | @respx.mock 185 | async def test_async_decorator(self): 186 | async with httpx.AsyncClient() as client: 187 | route = respx.get("https://example.org/") 188 | response = await client.get("https://example.org/") 189 | assert route.called 190 | assert response.status_code == 200 191 | 192 | async def test_async_ctx_manager(self): 193 | async with respx.mock: 194 | async with httpx.AsyncClient() as client: 195 | route = respx.get("https://example.org/") 196 | response = await client.get("https://example.org/") 197 | assert route.called 198 | assert response.status_code == 200 199 | ``` 200 | -------------------------------------------------------------------------------- /docs/img/respx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lundberg/respx/40b42ceac07477ca3ab8abbb9bdaf8ada76775e9/docs/img/respx.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |

2 | RESPX 3 |

4 | 5 |

6 | RESPX 7 |

8 | 9 | --- 10 | 11 | Mock [HTTPX](https://www.python-httpx.org/) with awesome request patterns and response side effects. 12 | 13 | [![tests](https://img.shields.io/github/actions/workflow/status/lundberg/respx/test.yml?branch=master&label=tests&logo=github&logoColor=white&style=flat-square)](https://github.com/lundberg/respx/actions/workflows/test.yml) [![codecov](https://img.shields.io/codecov/c/github/lundberg/respx?logo=codecov&logoColor=white&style=flat-square)](https://codecov.io/gh/lundberg/respx) [![PyPi Version](https://img.shields.io/pypi/v/respx?logo=pypi&logoColor=white&style=flat-square)](https://pypi.org/project/respx/) [![Python Versions](https://img.shields.io/pypi/pyversions/respx?logo=python&logoColor=white&style=flat-square)](https://pypi.org/project/respx/) 14 | 15 | 16 | ## QuickStart 17 | 18 | RESPX is a simple, *yet powerful*, utility for mocking out the [HTTPX](https://www.python-httpx.org/), *and [HTTP Core](https://www.encode.io/httpcore/)*, libraries. 19 | 20 | Start by [patching](guide.md#mock-httpx) `HTTPX`, using `respx.mock`, then add request [routes](guide.md#routing-requests) to mock [responses](guide.md#mocking-responses). 21 | 22 | ``` python 23 | import httpx 24 | import respx 25 | 26 | from httpx import Response 27 | 28 | 29 | @respx.mock 30 | def test_example(): 31 | my_route = respx.get("https://foo.bar/").mock(return_value=Response(204)) 32 | response = httpx.get("https://foo.bar/") 33 | assert my_route.called 34 | assert response.status_code == 204 35 | ``` 36 | 37 | > Read the [User Guide](guide.md) for a complete walk-through. 38 | 39 | 40 | ### pytest + httpx 41 | 42 | For a neater `pytest` experience, RESPX includes a `respx_mock` *fixture* for easy `HTTPX` mocking, along with an optional `respx` *marker* to fine-tune the mock [settings](api.md#configuration). 43 | 44 | ``` python 45 | import httpx 46 | import pytest 47 | 48 | 49 | def test_default(respx_mock): 50 | respx_mock.get("https://foo.bar/").mock(return_value=httpx.Response(204)) 51 | response = httpx.get("https://foo.bar/") 52 | assert response.status_code == 204 53 | 54 | 55 | @pytest.mark.respx(base_url="https://foo.bar") 56 | def test_with_marker(respx_mock): 57 | respx_mock.get("/baz/").mock(return_value=httpx.Response(204)) 58 | response = httpx.get("https://foo.bar/baz/") 59 | assert response.status_code == 204 60 | ``` 61 | 62 | 63 | ## Installation 64 | 65 | Install with pip: 66 | 67 | ``` console 68 | $ pip install respx 69 | ``` 70 | 71 | Requires Python 3.8+ and HTTPX 0.25+. 72 | See [Changelog](https://github.com/lundberg/respx/blob/master/CHANGELOG.md) for older HTTPX compatibility. 73 | -------------------------------------------------------------------------------- /docs/migrate.md: -------------------------------------------------------------------------------- 1 | # Migrate from requests 2 | 3 | ## responses 4 | 5 | Here's a few examples on how to migrate your code *from* the `responses` library *to* `respx`. 6 | 7 | ### Patching the Client 8 | 9 | #### Decorator 10 | 11 | ``` python 12 | @responses.activate 13 | def test_foo(): 14 | ... 15 | ``` 16 | ``` python 17 | @respx.mock 18 | def test_foo(): 19 | ... 20 | ``` 21 | > See [Router Settings](guide.md#router-settings) for more details. 22 | 23 | #### Context Manager 24 | 25 | ``` python 26 | def test_foo(): 27 | with responses.RequestsMock() as rsps: 28 | ... 29 | ``` 30 | ``` python 31 | def test_foo(): 32 | with respx.mock() as respx_mock: 33 | ... 34 | ``` 35 | > See [Router Settings](guide.md#router-settings) for more details. 36 | 37 | #### unittest setUp 38 | 39 | ``` python 40 | def setUp(self): 41 | self.responses = responses.RequestsMock() 42 | self.responses.start() 43 | self.addCleanup(self.responses.stop) 44 | ``` 45 | ``` python 46 | def setUp(self): 47 | self.respx_mock = respx.mock() 48 | self.respx_mock.start() 49 | self.addCleanup(self.respx_mock.stop) 50 | ``` 51 | > See [unittest examples](examples.md#reuse-setup-teardown) for more details. 52 | 53 | ### Mock a Response 54 | 55 | ``` python 56 | responses.add( 57 | responses.GET, "https://example.org/", 58 | json={"foo": "bar"}, 59 | status=200, 60 | ) 61 | ``` 62 | ``` python 63 | respx.get("https://example.org/").respond(200, json={"foo": "bar"}) 64 | ``` 65 | > See [Routing Requests](guide.md#routing-requests) and [Mocking Responses](guide.md#mocking-responses) for more details. 66 | 67 | ### Mock an Exception 68 | 69 | ``` python 70 | responses.add( 71 | responses.GET, "https://example.org/", 72 | body=Exception("..."), 73 | ) 74 | ``` 75 | ``` python 76 | respx.get("https://example.org/").mock(side_effect=ConnectError) 77 | ``` 78 | > See [Exception Side Effect](guide.md#exceptions) for more details. 79 | 80 | ### Subsequent Responses 81 | 82 | ``` python 83 | responses.add(responses.GET, "https://example.org/", status=200) 84 | responses.add(responses.GET, "https://example.org/", status=500) 85 | ``` 86 | ``` python 87 | respx.get("https://example.org/").mock( 88 | side_effect=[Response(200), Response(500)] 89 | ) 90 | ``` 91 | > See [Iterable Side Effect](guide.md#iterable) for more details. 92 | 93 | ### Callbacks 94 | 95 | ``` python 96 | def my_callback(request): 97 | headers = {"Content-Type": "application/json"} 98 | body = {"foo": "bar"} 99 | return (200, headers, json.dumps(resp_body)) 100 | 101 | responses.add_callback( 102 | responses.GET, "http://example.org/", 103 | callback=my_callback, 104 | ) 105 | ``` 106 | ``` python 107 | def my_side_effect(request, route): 108 | return Response(200, json={"foo": "bar"}) 109 | 110 | respx.get("https://example.org/").mock(side_effect=my_side_effect) 111 | ``` 112 | > See [Mock with a Side Effect](guide.md#mock-with-a-side-effect) for more details. 113 | 114 | ### History and Assertions 115 | 116 | ### History 117 | 118 | ``` python 119 | responses.calls[0].request 120 | responses.calls[0].response 121 | ``` 122 | ``` python 123 | respx.calls[0].request 124 | respx.calls[0].response 125 | 126 | request, response = respx.calls[0] 127 | respx.calls.last.response 128 | ``` 129 | > See [Call History](guide.md#call-history) for more details. 130 | 131 | #### Call Count 132 | ``` python 133 | responses.assert_call_count("http://example.org/", 1) 134 | ``` 135 | ``` python 136 | route = respx.get("https://example.org/") 137 | assert route.call_count == 1 138 | ``` 139 | > See [Call History](guide.md#call-history) for more details. 140 | 141 | #### All Called 142 | ``` python 143 | with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: 144 | ... 145 | ``` 146 | ``` python 147 | with respx.mock(assert_all_called=False) as respx_mock: 148 | ... 149 | ``` 150 | > See [Assert all Called](guide.md#assert-all-called) for more details. 151 | 152 | ### Modify Mocked Response 153 | 154 | ``` python 155 | responses.add(responses.GET, "http://example.org/", json={"data": 1}) 156 | responses.replace(responses.GET, "http://example.org/", json={"data": 2}) 157 | ``` 158 | ``` python 159 | respx.get("https://example.org/").respond(json={"data": 1}) 160 | respx.get("https://example.org/").respond(json={"data": 2}) 161 | ``` 162 | 163 | 164 | ### Pass Through Requests 165 | 166 | ``` python 167 | responses.add_passthru("https://example.org/") 168 | ``` 169 | ``` python 170 | respx.route(url="https://example.org/").pass_through() 171 | ``` 172 | > See [Pass Through](guide.md#pass-through) for more details. 173 | 174 | 175 | ## requests-mock 176 | 177 | *todo ... contribution welcome* ;-) 178 | -------------------------------------------------------------------------------- /docs/mocking.md: -------------------------------------------------------------------------------- 1 | # Mock HTTPX 2 | 3 | RESPX is a mock router, [capturing](guide.md#mock-httpx) requests sent by `HTTPX`, [mocking](guide.md#mocking-responses) their responses. 4 | 5 | Inspired by the flexible query API of the [Django](https://www.djangoproject.com/) ORM, requests are filtered and matched against routes and their request [patterns](api.md#patterns) and [lookups](api.md#lookups). 6 | 7 | Request [patterns](api.md#patterns) are *bits* of the request, like `host` `method` `path` etc, 8 | with given [lookup](api.md#lookups) values, combined using *bitwise* [operators](api.md#operators) to form a `Route`, 9 | i.e. `respx.route(path__regex=...)` 10 | 11 | A captured request, [matching](guide.md#routing-requests) a `Route`, resolves to a [mocked](guide.md#mock-a-response) `httpx.Response`, or triggers a given [side effect](guide.md#mock-with-a-side-effect). 12 | To skip mocking a specific request, a route can be marked to [pass through](guide.md#pass-through). 13 | 14 | > Read the [User Guide](guide.md) for a complete walk-through. 15 | -------------------------------------------------------------------------------- /docs/stylesheets/slate.css: -------------------------------------------------------------------------------- 1 | [data-md-color-scheme="slate"] { 2 | --md-hue: 245; 3 | --md-typeset-a-color: #9772d7; 4 | --md-default-bg-color: hsla(var(--md-hue),15%,11%,1); 5 | --md-footer-bg-color: hsla(var(--md-hue),15%,5%,0.87); 6 | --md-footer-bg-color--dark: hsla(var(--md-hue),15%,1%,1); 7 | } 8 | -------------------------------------------------------------------------------- /docs/upgrade.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | As of RESPX version `0.15.0`, the API has changed, but kept with **deprecation** warnings, later to be **broken** for backward compatibility in `0.16.0`. 4 | 5 | The biggest change involved *separating* request pattern *arguments* from response details. 6 | 7 | This brings the RESPX request matching API closer to the `HTTPX` client API, and the response mocking aligned with the python `Mock` API. 8 | 9 | ## Responses 10 | Response details are now mocked separatelty: 11 | ``` python 12 | # Previously 13 | respx.post("https://some.url/", status_code=200, content={"x": 1}) 14 | 15 | # Now 16 | respx.post("https://some.url/").mock(return_value=Response(200, json={"x": 1})) 17 | respx.post("https://some.url/").respond(200, json={"x": 1}) 18 | respx.post("https://some.url/") % dict(json={"x": 1}) 19 | ``` 20 | 21 | The `.add` API has changed to `.route`: 22 | ``` python 23 | # Previously 24 | respx.add("POST", "https://some.url/", content="foobar") 25 | 26 | # Now 27 | respx.route(method="POST", url="https://some.url/").respond(content="foobar") 28 | ``` 29 | 30 | ## Callbacks 31 | Callbacks and simulated errors are now *side effects*: 32 | ``` python 33 | # Previously 34 | respx.post("https://some.url/", content=callback) 35 | respx.post("https://some.url/", content=Exception()) 36 | respx.add(callback) 37 | 38 | # Now 39 | respx.post("https://some.url/").mock(side_effect=callback) 40 | respx.post("https://some.url/").mock(side_effect=Exception) 41 | respx.route().mock(side_effect=callback) 42 | ``` 43 | 44 | ## Stacking 45 | Repeating a mocked response, for stacking, is now solved with *side effects*: 46 | ``` python 47 | # Previously 48 | respx.post("https://some.url/", status_code=404) 49 | respx.post("https://some.url/", status_code=200) 50 | 51 | # Now 52 | respx.post("https://some.url/").mock( 53 | side_effect=[ 54 | Response(404), 55 | Response(200), 56 | ], 57 | ) 58 | ``` 59 | > **Note:** Repeating a route in `0.15.0+` replaces any existing route with same pattern. 60 | 61 | ## Aliasing 62 | Aliases changed to *named routes*: 63 | ``` python 64 | # Previously 65 | respx.post("https://example.org/", alias="example") 66 | assert respx.aliases["example"].called 67 | 68 | # Now 69 | respx.post("https://example.org/", name="example") 70 | assert respx.routes["example"].called 71 | ``` 72 | 73 | ## History 74 | Call history *renamed*: 75 | ``` python 76 | # Previously 77 | assert respx.stats.call_count == 1 78 | 79 | # Now 80 | assert respx.calls.call_count == 1 81 | ``` 82 | 83 | ## MockTransport 84 | The `respx.MockTransport` should no longer be used as a mock router, use `respx.mock(...)`. 85 | ``` python 86 | # Previously 87 | my_mock = respx.MockTransport(assert_all_called=False) 88 | 89 | # Now 90 | my_mock = respx.mock(assert_all_called=False) 91 | ``` 92 | -------------------------------------------------------------------------------- /docs/versions/0.14.0/api.md: -------------------------------------------------------------------------------- 1 | !!! attention "Warning" 2 | This is the documentation of the older version `0.14.0`. See [latest](../../../) for current release. 3 | 4 | # Developer Interface - Version 0.14.0 5 | 6 | ## Mocking Responses 7 | 8 | ### HTTP Method API 9 | 10 | For regular and simple use, use the HTTP method shorthands. 11 | See [Request API](#request-api) for parameters. 12 | 13 | > ::: respx.get 14 | 15 | > respx.options(...) 16 | 17 | > respx.head(...) 18 | 19 | > respx.post(...) 20 | 21 | > respx.put(...) 22 | 23 | > respx.patch(...) 24 | 25 | > respx.delete(...) 26 | 27 | 28 | ### Request API 29 | 30 | For full control, use the core `add` method. 31 | 32 | > ::: respx.add 33 | > :docstring: 34 | > 35 | > **Parameters:** 36 | > 37 | > * **method** - *str | callable | RequestPattern* 38 | > Request HTTP method, or [Request callback](#request-callback), to match. 39 | > * **url** - *(optional) str | pattern | tuple (httpcore) | httpx.URL* 40 | > Request exact URL, or [URL pattern](#url-pattern), to match. 41 | > * **params** - *(optional) str | list | dict* 42 | > Request URL params to merge with url. 43 | > * **status_code** - *(optional) int - default: `200`* 44 | > Response status code to mock. 45 | > * **headers** - *(optional) dict* 46 | > Response headers to mock. 47 | > * **content_type** - *(optional) str* 48 | > Response Content-Type header value to mock. 49 | > * **content** - *(optional) bytes | str | list | dict | callable | exception - default `b""`* 50 | > Response content to mock. - *See [Response Content](#response-content).* 51 | > * **text** - *(optional) str* 52 | > Response *text* content to mock, with automatic content type header. 53 | > * **html** - *(optional) str* 54 | > Response *html* content to mock, with automatic content type header. 55 | > * **json** - *(optional) str | list | dict* 56 | > Response *json* content to mock, with automatic content type header. 57 | > * **pass_through** - *(optional) bool - default `False`* 58 | > Mark matched request to pass-through to real server, *e.g. don't mock*. 59 | > * **alias** - *(optional) str* 60 | > Name this request pattern. - *See [Call Statistics](#call-statistics).* 61 | 62 | --- 63 | 64 | ## Matching Requests 65 | 66 | ### Exact URL 67 | 68 | To match and mock a request by an exact URL, pass the `url` parameter as a *string*. 69 | 70 | ``` python 71 | respx.get("https://foo.bar/", status_code=204) 72 | ``` 73 | 74 | 75 | ### URL pattern 76 | 77 | Instead of matching an [exact URL](#exact-url), you can pass a *compiled regex* to match the request URL. 78 | 79 | ``` python 80 | import httpx 81 | import re 82 | import respx 83 | 84 | 85 | @respx.mock 86 | def test_something(): 87 | url_pattern = re.compile(r"^https://foo.bar/\w+/$") 88 | respx.get(url_pattern, content="Baz") 89 | response = httpx.get("https://foo.bar/baz/") 90 | assert response.text == "Baz" 91 | ``` 92 | !!! tip 93 | Named groups in the regex pattern will be passed as `kwargs` to the response content [callback](#content-callback), if used. 94 | 95 | 96 | ### Base URL 97 | 98 | When adding a lot of request patterns sharing the same domain/prefix, you can configure RESPX with a `base_url` to use as the base when matching URLs. 99 | 100 | Like `url`, the `base_url` can also be passed as a *compiled regex*, with optional named groups. 101 | 102 | ``` python 103 | import httpx 104 | import respx 105 | 106 | 107 | @respx.mock(base_url="https://foo.bar") 108 | async def test_something(respx_mock): 109 | async with httpx.AsyncClient(base_url="https://foo.bar") as client: 110 | request = respx_mock.get("/baz/", content="Baz") 111 | response = await client.get("/baz/") 112 | assert response.text == "Baz" 113 | ``` 114 | 115 | 116 | ### Request callback 117 | 118 | For full control of what request to **match** and what response to **mock**, 119 | pass a *callback* function as the `add(method, ...)` parameter. 120 | The callback's response argument will be pre-populated with any additional response parameters. 121 | 122 | ``` python 123 | import httpx 124 | import respx 125 | 126 | 127 | def match_and_mock(request, response): 128 | """ 129 | Return `None` to not match the request. 130 | Return the `response` to match and mock this request. 131 | Return the `request` for pass-through behaviour. 132 | """ 133 | if request.method != "POST": 134 | return None 135 | 136 | if "X-Auth-Token" not in request.headers: 137 | response.status_code = 401 138 | else: 139 | response.content = "OK" 140 | 141 | return response 142 | 143 | 144 | @respx.mock 145 | def test_something(): 146 | custom_request = respx.add(match_and_mock, status_code=201) 147 | respx.get("https://foo.bar/baz/") 148 | 149 | response = httpx.get("https://foo.bar/baz/") 150 | assert response.status_code == 200 151 | assert not custom_request.called 152 | 153 | response = httpx.post("https://foo.bar/baz/") 154 | assert response.status_code == 401 155 | assert custom_request.called 156 | 157 | response = httpx.post("https://foo.bar/baz/", headers={"X-Auth-Token": "x"}) 158 | assert response.status_code == 201 159 | assert custom_request.call_count == 2 160 | ``` 161 | 162 | 163 | ### Repeated patterns 164 | 165 | If you mock several responses with the same *request pattern*, they will be matched in order, and popped til the last one. 166 | 167 | ``` python 168 | import httpx 169 | import respx 170 | 171 | 172 | @respx.mock 173 | def test_something(): 174 | respx.get("https://foo.bar/baz/123/", status_code=404) 175 | respx.get("https://foo.bar/baz/123/", content={"id": 123}) 176 | respx.post("https://foo.bar/baz/", status_code=201) 177 | 178 | response = httpx.get("https://foo.bar/baz/123/") 179 | assert response.status_code == 404 # First match 180 | 181 | response = httpx.post("https://foo.bar/baz/") 182 | assert response.status_code == 201 183 | 184 | response = httpx.get("https://foo.bar/baz/123/") 185 | assert response.status_code == 200 # Second match 186 | assert response.json() == {"id": 123} 187 | ``` 188 | 189 | ### Manipulating Existing Patterns 190 | 191 | Clearing all existing patterns: 192 | 193 | ``` python 194 | import respx 195 | 196 | 197 | @respx.mock 198 | def test_something(): 199 | respx.get("https://foo.bar/baz", status_code=404) 200 | respx.clear() # no patterns will be matched after this call 201 | ``` 202 | 203 | Removing and optionally re-using an existing pattern by alias: 204 | 205 | ``` python 206 | import respx 207 | 208 | 209 | @respx.mock 210 | def test_something(): 211 | respx.get("https://foo.bar/", status_code=404, alias="index") 212 | request_pattern = respx.pop("index") 213 | respx.get(request_pattern.url, status_code=200) 214 | ``` 215 | 216 | --- 217 | 218 | ## Response Content 219 | 220 | ### JSON content 221 | 222 | To mock a response with json content, pass a `list` or a `dict`. 223 | The `Content-Type` header will automatically be set to `application/json`. 224 | 225 | ``` python 226 | import httpx 227 | import respx 228 | 229 | 230 | @respx.mock 231 | def test_something(): 232 | respx.get("https://foo.bar/baz/123/", content={"id": 123}) 233 | response = httpx.get("https://foo.bar/baz/123/") 234 | assert response.json() == {"id": 123} 235 | ``` 236 | 237 | ### Content callback 238 | 239 | If you need dynamic response content, pass a *callback* function. 240 | When used together with a [URL pattern](#url-pattern), named groups will be passed 241 | as `kwargs`. 242 | 243 | ``` python 244 | import httpx 245 | import re 246 | import respx 247 | 248 | 249 | def some_content(request, slug=None): 250 | """ Return bytes, str, list or a dict. """ 251 | return {"slug": slug} 252 | 253 | 254 | @respx.mock 255 | def test_something(): 256 | url_pattern = r"^https://foo.bar/(?P\w+)/$") 257 | respx.get(url_pattern, content=some_content) 258 | 259 | response = httpx.get("https://foo.bar/apa/") 260 | assert response.json() == {"slug": "apa"} 261 | ``` 262 | 263 | 264 | ### Request Error 265 | 266 | To simulate a failing request, *like a connection error*, pass an `Exception` instance. 267 | This is useful when you need to test proper `HTTPX` error handling in your app. 268 | 269 | ``` python 270 | import httpx 271 | import httpcore 272 | import respx 273 | 274 | 275 | @respx.mock 276 | def test_something(): 277 | respx.get("https://foo.bar/", content=httpcore.ConnectTimeout()) 278 | response = httpx.get("https://foo.bar/") # Will raise 279 | ``` 280 | 281 | --- 282 | 283 | ## Built-in Assertions 284 | 285 | RESPX has the following built-in assertion checks: 286 | 287 | > * **assert_all_mocked** 288 | > Asserts that all captured `HTTPX` requests are mocked. Defaults to `True`. 289 | > * **assert_all_called** 290 | > Asserts that all mocked request patterns were called. Defaults to `True`. 291 | 292 | Configure checks by using the `respx.mock` decorator / context manager *with* parentheses. 293 | 294 | ``` python 295 | @respx.mock(assert_all_called=False) 296 | def test_something(respx_mock): 297 | respx_mock.get("https://some.url/") # OK 298 | respx_mock.get("https://foo.bar/") 299 | 300 | response = httpx.get("https://foo.bar/") 301 | assert response.status_code == 200 302 | assert respx_mock.calls.call_count == 1 303 | ``` 304 | ``` python 305 | with respx.mock(assert_all_mocked=False) as respx_mock: 306 | response = httpx.get("https://foo.bar/") # OK 307 | assert response.status_code == 200 308 | assert respx_mock.calls.call_count == 1 309 | ``` 310 | 311 | !!! attention "Without Parentheses" 312 | When using the *global* scope `@respx.mock` decorator / context manager, `assert_all_called` is **disabled**. 313 | 314 | --- 315 | 316 | ## Call History 317 | 318 | The `respx` API includes a `.calls` object, containing captured (`request`, `response`) named tuples and MagicMock's *bells and whistles*, i.e. `call_count`, `assert_called` etc. 319 | 320 | 321 | ### Retrieving mocked calls 322 | A matched and mocked `Call` can be retrieved from call history, by either unpacking... 323 | 324 | ``` python 325 | request, response = respx.calls.last 326 | request, response = respx.calls[-2] # by call order 327 | ``` 328 | 329 | ...or by accessing `request` or `response` directly... 330 | 331 | ``` python 332 | last_response = respx.calls.last.response 333 | 334 | assert respx.calls.last.request.call_count == 1 335 | assert respx.calls.last.response.status_code == 200 336 | ``` 337 | 338 | !!! attention "Deprecation Warning" 339 | As of version `0.14.0`, statistics via `respx.stats` is deprecated, in favour of `respx.calls`. 340 | 341 | ### Request Pattern calls 342 | Each mocked response *request pattern* has its own `.calls`, along with `.called` and `.call_count ` stats shortcuts. 343 | 344 | Example using locally added request pattern: 345 | ``` python 346 | import httpx 347 | import respx 348 | 349 | 350 | @respx.mock 351 | def test_something(): 352 | request = respx.post("https://foo.bar/baz/", status_code=201) 353 | httpx.post("https://foo.bar/baz/") 354 | assert request.called 355 | assert request.call_count == 1 356 | assert request.calls.last.response.status_code == 201 357 | request.calls.assert_called_once() 358 | ``` 359 | 360 | Example using globally aliased request pattern: 361 | ``` python 362 | import httpx 363 | import respx 364 | 365 | # Added somewhere outside the test 366 | respx.get("https://foo.bar/", alias="index") 367 | 368 | @respx.mock 369 | def test_something(): 370 | httpx.get("https://foo.bar/") 371 | assert respx.aliases["index"].called 372 | assert respx.aliases["index"].call_count == 1 373 | last_index_response = respx.aliases["index"].calls.last.response 374 | ``` 375 | 376 | ### Reset stats 377 | To reset stats during a test case, *without stop mocking*, use `respx.reset()`. 378 | 379 | ``` python 380 | import httpx 381 | import respx 382 | 383 | 384 | @respx.mock 385 | def test_something(): 386 | respx.post("https://foo.bar/baz/") 387 | httpx.post("https://foo.bar/baz/") 388 | assert respx.calls.call_count == 1 389 | request.calls.assert_called_once() 390 | 391 | respx.reset() 392 | assert len(respx.calls) == 0 393 | assert respx.calls.call_count == 0 394 | respx.calls.assert_not_called() 395 | ``` 396 | 397 | ### Examples 398 | Here's a handful example usages of the call stats API. 399 | 400 | ``` python 401 | import httpx 402 | import respx 403 | 404 | 405 | @respx.mock 406 | def test_something(): 407 | # Mock some calls 408 | respx.get("https://foo.bar/", alias="index") 409 | baz_request = respx.post("https://foo.bar/baz/", status_code=201) 410 | 411 | # Make some calls 412 | httpx.get("https://foo.bar/") 413 | httpx.post("https://foo.bar/baz/") 414 | 415 | # Assert mocked 416 | assert respx.aliases["index"].called 417 | assert respx.aliases["index"].call_count == 1 418 | 419 | assert baz_request.called 420 | assert baz_request.call_count == 1 421 | baz_request.calls.assert_called_once() 422 | 423 | # Global stats increased 424 | assert respx.calls.call_count == 2 425 | 426 | # Assert responses 427 | assert respx.aliases["index"].calls.last.response.status_code == 200 428 | assert respx.calls.last.response is baz_request.calls.last.response 429 | assert respx.calls.last.response.status_code == 201 430 | 431 | # Reset 432 | respx.reset() 433 | assert len(respx.calls) == 0 434 | assert respx.calls.call_count == 0 435 | respx.calls.assert_not_called() 436 | ``` 437 | -------------------------------------------------------------------------------- /docs/versions/0.14.0/mocking.md: -------------------------------------------------------------------------------- 1 | !!! attention "Warning" 2 | This is the documentation of the older version `0.14.0`. See [latest](../../../) for current release. 3 | 4 | # Mock HTTPX - Version 0.14.0 5 | 6 | To mock out `HTTPX` *and/or* `HTTP Core`, use the `respx.mock` decorator / context manager. 7 | 8 | Optionally configure [built-in assertion](api.md#built-in-assertions) checks and [base URL](api.md#base-url) 9 | with `respx.mock(...)`. 10 | 11 | 12 | ## Using the Decorator 13 | 14 | ``` python 15 | import httpx 16 | import respx 17 | 18 | 19 | @respx.mock 20 | def test_something(): 21 | request = respx.get("https://foo.bar/", content="foobar") 22 | response = httpx.get("https://foo.bar/") 23 | assert request.called 24 | assert response.status_code == 200 25 | assert response.text == "foobar" 26 | ``` 27 | 28 | 29 | ## Using the Context Manager 30 | 31 | ``` python 32 | import httpx 33 | import respx 34 | 35 | 36 | with respx.mock: 37 | request = respx.get("https://foo.bar/", content="foobar") 38 | response = httpx.get("https://foo.bar/") 39 | assert request.called 40 | assert response.status_code == 200 41 | assert response.text == "foobar" 42 | ``` 43 | 44 | !!! note "NOTE" 45 | You can also start and stop mocking `HTTPX` manually, by calling `respx.start()` and `respx.stop()`. 46 | 47 | 48 | ## Using the mock Transports 49 | 50 | The built-in transports are the base of all mocking and patching in RESPX. 51 | 52 | *In fact*, `respx.mock` is an actual instance of `MockTransport`. 53 | 54 | ### MockTransport 55 | ``` python 56 | import httpx 57 | import respx 58 | 59 | 60 | mock_transport = respx.MockTransport() 61 | request = mock_transport.get("https://foo.bar/", content="foobar") 62 | 63 | with mock_transport: 64 | response = httpx.get("https://foo.bar/") 65 | assert request.called 66 | assert response.status_code == 200 67 | assert response.text == "foobar" 68 | ``` 69 | 70 | ### SyncMockTransport 71 | 72 | If you don't *need* to patch the original `HTTPX`/`HTTP Core` transports, then use the `SyncMockTransport` or [`AsyncMockTransport`](#asyncmocktransport) directly, by passing the `transport` *arg* when instantiating your `HTTPX` client, or alike. 73 | 74 | ``` python 75 | import httpx 76 | import respx 77 | 78 | 79 | mock_transport = respx.SyncMockTransport() 80 | request = mock_transport.get("https://foo.bar/", content="foobar") 81 | 82 | with httpx.Client(transport=mock_transport) as client: 83 | response = client.get("https://foo.bar/") 84 | assert request.called 85 | assert response.status_code == 200 86 | assert response.text == "foobar" 87 | ``` 88 | 89 | ### AsyncMockTransport 90 | 91 | ``` python 92 | import httpx 93 | import respx 94 | 95 | 96 | mock_transport = respx.AsyncMockTransport() 97 | request = mock_transport.get("https://foo.bar/", content="foobar") 98 | 99 | async with httpx.AsyncClient(transport=mock_transport) as client: 100 | response = await client.get("https://foo.bar/") 101 | assert request.called 102 | assert response.status_code == 200 103 | assert response.text == "foobar" 104 | ``` 105 | 106 | !!! note "NOTE" 107 | The mock transports takes the same configuration arguments as the decorator / context manager. 108 | 109 | 110 | ## Global Setup & Teardown 111 | 112 | ### pytest 113 | ``` python 114 | # conftest.py 115 | import pytest 116 | import respx 117 | 118 | 119 | @pytest.fixture 120 | def mocked_api(): 121 | with respx.mock(base_url="https://foo.bar") as respx_mock: 122 | respx_mock.get("/users/", content=[], alias="list_users") 123 | ... 124 | yield respx_mock 125 | ``` 126 | 127 | ``` python 128 | # test_api.py 129 | import httpx 130 | 131 | 132 | def test_list_users(mocked_api): 133 | response = httpx.get("https://foo.bar/users/") 134 | request = mocked_api["list_users"] 135 | assert request.called 136 | assert response.json() == [] 137 | ``` 138 | 139 | !!! tip 140 | Use a **session** scoped fixture `@pytest.fixture(scope="session")` when your fixture contains **multiple** 141 | endpoints that not necessary gets called by a single test case, or [disable](api.md#built-in-assertions) 142 | the built-in `assert_all_called` check. 143 | 144 | 145 | ### unittest 146 | 147 | ``` python 148 | # testcases.py 149 | 150 | class MockedAPIMixin: 151 | def setUp(self): 152 | self.mocked_api = respx.mock(base_url="https://foo.bar") 153 | self.mocked_api.get("/users/", content=[], alias="list_users") 154 | ... 155 | self.mocked_api.start() 156 | 157 | def tearDown(self): 158 | self.mocked_api.stop() 159 | ``` 160 | ``` python 161 | # test_api.py 162 | 163 | import unittest 164 | import httpx 165 | 166 | from .testcases import MockedAPIMixin 167 | 168 | 169 | class MyTestCase(MockedAPIMixin, unittest.TestCase): 170 | def test_list_users(self): 171 | response = httpx.get("https://foo.bar/users/") 172 | request = self.mocked_api["list_users"] 173 | assert request.called 174 | assert response.json() == [] 175 | ``` 176 | 177 | !!! tip 178 | Use `setUpClass` and `tearDownClass` when you mock **multiple** endpoints that not 179 | necessary gets called by a single test method, or [disable](api.md#built-in-assertions) 180 | the built-in `assert_all_called` check. 181 | 182 | 183 | ## Async Support 184 | 185 | You can use `respx.mock` in both **sync** and **async** contexts to mock out `HTTPX` responses. 186 | 187 | ### pytest 188 | ``` python 189 | @respx.mock 190 | @pytest.mark.asyncio 191 | async def test_something(): 192 | async with httpx.AsyncClient() as client: 193 | request = respx.get("https://foo.bar/", content="foobar") 194 | response = await client.get("https://foo.bar/") 195 | assert request.called 196 | assert response.text == "foobar" 197 | ``` 198 | ``` python 199 | @pytest.mark.asyncio 200 | async def test_something(): 201 | async with respx.mock: 202 | async with httpx.AsyncClient() as client: 203 | request = respx.get("https://foo.bar/", content="foobar") 204 | response = await client.get("https://foo.bar/") 205 | assert request.called 206 | assert response.text == "foobar" 207 | ``` 208 | 209 | **Session Scoped Fixtures** 210 | 211 | If a session scoped RESPX fixture is used in an async context, you also need to broaden the `pytest-asyncio` 212 | [event_loop](https://github.com/pytest-dev/pytest-asyncio#event_loop) fixture. 213 | You can use the `session_event_loop` utility for this. 214 | 215 | ``` python 216 | # conftest.py 217 | 218 | import pytest 219 | import respx 220 | from respx.fixtures import session_event_loop as event_loop # noqa: F401 221 | 222 | 223 | @pytest.fixture(scope="session") 224 | async def mocked_api(event_loop): # noqa: F811 225 | async with respx.mock(base_url="https://foo.bar") as respx_mock: 226 | ... 227 | yield respx_mock 228 | ``` 229 | 230 | ### unittest 231 | 232 | ``` python 233 | import asynctest 234 | 235 | 236 | class MyTestCase(asynctest.TestCase): 237 | @respx.mock 238 | async def test_something(self): 239 | async with httpx.AsyncClient() as client: 240 | request = respx.get("https://foo.bar/", content="foobar") 241 | response = await client.get("https://foo.bar/") 242 | assert request.called 243 | assert response.text == "foobar" 244 | 245 | async def test_something(self): 246 | async with respx.mock: 247 | async with httpx.AsyncClient() as client: 248 | request = respx.get("https://foo.bar/", content="foobar") 249 | response = await client.get("https://foo.bar/") 250 | assert request.called 251 | assert response.text == "foobar" 252 | ``` 253 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flakeUtils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1710777701, 24 | "narHash": "sha256-hMyIBLJY2VjsM/dOmXta5XdyxcuQoKUkm4M/K0c0xlo=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "f78a4dcd452449992e526fd88a60a2d45e0ae969", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "repo": "nixpkgs", 33 | "type": "github" 34 | } 35 | }, 36 | "nixpkgs22": { 37 | "locked": { 38 | "lastModified": 1669833724, 39 | "narHash": "sha256-/HEZNyGbnQecrgJnfE8d0WC5c1xuPSD2LUpB6YXlg4c=", 40 | "owner": "nixos", 41 | "repo": "nixpkgs", 42 | "rev": "4d2b37a84fad1091b9de401eb450aae66f1a741e", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "nixos", 47 | "ref": "22.11", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "nixpkgsUnstable": { 53 | "locked": { 54 | "lastModified": 1710734606, 55 | "narHash": "sha256-rFJl+WXfksu2NkWJWKGd5Km17ZGEjFg9hOQNwstsoU8=", 56 | "owner": "nixos", 57 | "repo": "nixpkgs", 58 | "rev": "79bb4155141a5e68f2bdee2bf6af35b1d27d3a1d", 59 | "type": "github" 60 | }, 61 | "original": { 62 | "owner": "nixos", 63 | "ref": "nixpkgs-unstable", 64 | "repo": "nixpkgs", 65 | "type": "github" 66 | } 67 | }, 68 | "root": { 69 | "inputs": { 70 | "flakeUtils": "flakeUtils", 71 | "nixpkgs": "nixpkgs", 72 | "nixpkgs22": "nixpkgs22", 73 | "nixpkgsUnstable": "nixpkgsUnstable" 74 | } 75 | }, 76 | "systems": { 77 | "locked": { 78 | "lastModified": 1681028828, 79 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 80 | "owner": "nix-systems", 81 | "repo": "default", 82 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 83 | "type": "github" 84 | }, 85 | "original": { 86 | "owner": "nix-systems", 87 | "repo": "default", 88 | "type": "github" 89 | } 90 | } 91 | }, 92 | "root": "root", 93 | "version": 7 94 | } 95 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs"; 4 | nixpkgs22.url = "github:nixos/nixpkgs/22.11"; 5 | nixpkgsUnstable.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 6 | flakeUtils.url = "github:numtide/flake-utils"; 7 | }; 8 | outputs = { self, nixpkgs, nixpkgs22, nixpkgsUnstable, flakeUtils }: 9 | flakeUtils.lib.eachDefaultSystem (system: 10 | let 11 | pkgs = nixpkgs.legacyPackages.${system}; 12 | pkgs22 = nixpkgs22.legacyPackages.${system}; 13 | pkgsUnstable = nixpkgsUnstable.legacyPackages.${system}; 14 | in { 15 | packages = flakeUtils.lib.flattenTree { 16 | python313 = pkgs.python313; 17 | python312 = pkgs.python312; 18 | python311 = pkgs.python311; 19 | python310 = pkgs.python310; 20 | python39 = pkgs.python39; 21 | python38 = pkgs22.python38; 22 | go-task = pkgsUnstable.go-task; 23 | }; 24 | devShell = pkgs.mkShell { 25 | buildInputs = with self.packages.${system}; [ 26 | python313 27 | python312 28 | python311 29 | python310 30 | python39 31 | python38 32 | go-task 33 | ]; 34 | shellHook = '' 35 | [[ ! -d .venv ]] && \ 36 | echo "Creating virtualenv ..." && \ 37 | ${pkgs.python310}/bin/python -m \ 38 | venv --copies --upgrade-deps .venv > /dev/null 39 | source .venv/bin/activate 40 | ''; 41 | }; 42 | } 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: RESPX 2 | site_description: A utility for mocking out the Python HTTPX library. 3 | site_url: https://lundberg.github.io/respx/ 4 | 5 | theme: 6 | name: "material" 7 | icon: 8 | logo: "material/school" 9 | palette: 10 | - scheme: default 11 | media: "(prefers-color-scheme: light)" 12 | primary: "deep purple" 13 | accent: "deep purple" 14 | toggle: 15 | icon: material/weather-night 16 | name: Switch to dark mode 17 | - scheme: slate 18 | media: "(prefers-color-scheme: dark)" 19 | primary: "deep purple" 20 | accent: "deep purple" 21 | toggle: 22 | icon: material/weather-sunny 23 | name: Switch to light mode 24 | 25 | extra_css: 26 | - stylesheets/slate.css 27 | 28 | repo_name: lundberg/respx 29 | repo_url: https://github.com/lundberg/respx 30 | edit_uri: "" 31 | 32 | nav: 33 | - Introduction: "index.md" 34 | - User Guide: "guide.md" 35 | - API Reference: "api.md" 36 | - Test Case Examples: "examples.md" 37 | - Migrate from requests: "migrate.md" 38 | - Upgrading: "upgrade.md" 39 | - Older Versions: 40 | - 0.14.0: 41 | - Mock HTTPX: "versions/0.14.0/mocking.md" 42 | - Developer Interface: "versions/0.14.0/api.md" 43 | 44 | markdown_extensions: 45 | - admonition 46 | - codehilite: 47 | css_class: highlight 48 | - mkautodoc 49 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | nox.options.stop_on_first_error = True 4 | nox.options.reuse_existing_virtualenvs = True 5 | nox.options.keywords = "test + mypy" 6 | 7 | 8 | @nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]) 9 | def test(session): 10 | deps = ["pytest", "pytest-asyncio", "pytest-cov", "trio", "starlette", "flask"] 11 | session.install("--upgrade", *deps) 12 | session.install("-e", ".") 13 | 14 | if any(option in session.posargs for option in ("-k", "-x")): 15 | session.posargs.append("--no-cov") 16 | 17 | session.run("pytest", *session.posargs) 18 | 19 | 20 | @nox.session(python="3.8") 21 | def mypy(session): 22 | session.install("--upgrade", "mypy") 23 | session.install("-e", ".") 24 | session.run("mypy") 25 | 26 | 27 | @nox.session(python="3.10") 28 | def docs(session): 29 | deps = ["mkdocs", "mkdocs-material", "mkautodoc>=0.1.0"] 30 | session.install("--upgrade", *deps) 31 | session.install("-e", ".") 32 | args = session.posargs if session.posargs else ["build"] 33 | session.run("mkdocs", *args) 34 | -------------------------------------------------------------------------------- /respx/__init__.py: -------------------------------------------------------------------------------- 1 | from .__version__ import __version__ 2 | from .handlers import ASGIHandler, WSGIHandler 3 | from .models import MockResponse, Route 4 | from .router import MockRouter, Router 5 | from .utils import SetCookie 6 | 7 | from .api import ( # isort:skip 8 | mock, 9 | routes, 10 | calls, 11 | start, 12 | stop, 13 | clear, 14 | reset, 15 | pop, 16 | route, 17 | add, 18 | request, 19 | get, 20 | post, 21 | put, 22 | patch, 23 | delete, 24 | head, 25 | options, 26 | ) 27 | 28 | 29 | __all__ = [ 30 | "__version__", 31 | "MockResponse", 32 | "MockRouter", 33 | "ASGIHandler", 34 | "WSGIHandler", 35 | "Router", 36 | "Route", 37 | "SetCookie", 38 | "mock", 39 | "routes", 40 | "calls", 41 | "start", 42 | "stop", 43 | "clear", 44 | "reset", 45 | "pop", 46 | "route", 47 | "add", 48 | "request", 49 | "get", 50 | "post", 51 | "put", 52 | "patch", 53 | "delete", 54 | "head", 55 | "options", 56 | ] 57 | -------------------------------------------------------------------------------- /respx/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.22.0" 2 | -------------------------------------------------------------------------------- /respx/api.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Union, overload 2 | 3 | from .models import CallList, Route 4 | from .patterns import Pattern 5 | from .router import MockRouter 6 | from .types import DefaultType, URLPatternTypes 7 | 8 | mock = MockRouter(assert_all_called=False) 9 | 10 | routes = mock.routes 11 | calls: CallList = mock.calls 12 | 13 | 14 | def start() -> None: 15 | global mock 16 | mock.start() 17 | 18 | 19 | def stop(clear: bool = True, reset: bool = True) -> None: 20 | global mock 21 | mock.stop(clear=clear, reset=reset) 22 | 23 | 24 | def clear() -> None: 25 | global mock 26 | mock.clear() 27 | 28 | 29 | def reset() -> None: 30 | global mock 31 | mock.reset() 32 | 33 | 34 | @overload 35 | def pop(name: str) -> Route: 36 | ... # pragma: nocover 37 | 38 | 39 | @overload 40 | def pop(name: str, default: DefaultType) -> Union[Route, DefaultType]: 41 | ... # pragma: nocover 42 | 43 | 44 | def pop(name, default=...): 45 | global mock 46 | return mock.pop(name, default=default) 47 | 48 | 49 | def route(*patterns: Pattern, name: Optional[str] = None, **lookups: Any) -> Route: 50 | global mock 51 | return mock.route(*patterns, name=name, **lookups) 52 | 53 | 54 | def add(route: Route, *, name: Optional[str] = None) -> Route: 55 | global mock 56 | return mock.add(route, name=name) 57 | 58 | 59 | def request( 60 | method: str, 61 | url: Optional[URLPatternTypes] = None, 62 | *, 63 | name: Optional[str] = None, 64 | **lookups: Any, 65 | ) -> Route: 66 | global mock 67 | return mock.request(method, url, name=name, **lookups) 68 | 69 | 70 | def get( 71 | url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any 72 | ) -> Route: 73 | global mock 74 | return mock.get(url, name=name, **lookups) 75 | 76 | 77 | def post( 78 | url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any 79 | ) -> Route: 80 | global mock 81 | return mock.post(url, name=name, **lookups) 82 | 83 | 84 | def put( 85 | url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any 86 | ) -> Route: 87 | global mock 88 | return mock.put(url, name=name, **lookups) 89 | 90 | 91 | def patch( 92 | url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any 93 | ) -> Route: 94 | global mock 95 | return mock.patch(url, name=name, **lookups) 96 | 97 | 98 | def delete( 99 | url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any 100 | ) -> Route: 101 | global mock 102 | return mock.delete(url, name=name, **lookups) 103 | 104 | 105 | def head( 106 | url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any 107 | ) -> Route: 108 | global mock 109 | return mock.head(url, name=name, **lookups) 110 | 111 | 112 | def options( 113 | url: Optional[URLPatternTypes] = None, *, name: Optional[str] = None, **lookups: Any 114 | ) -> Route: 115 | global mock 116 | return mock.options(url, name=name, **lookups) 117 | -------------------------------------------------------------------------------- /respx/fixtures.py: -------------------------------------------------------------------------------- 1 | try: 2 | import pytest 3 | except ImportError: # pragma: nocover 4 | pass 5 | else: 6 | import asyncio 7 | 8 | @pytest.fixture(scope="session") 9 | def session_event_loop(): 10 | loop = asyncio.get_event_loop_policy().new_event_loop() 11 | yield loop 12 | loop.close() 13 | -------------------------------------------------------------------------------- /respx/handlers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable 2 | 3 | import httpx 4 | 5 | 6 | class TransportHandler: 7 | def __init__(self, transport: httpx.BaseTransport) -> None: 8 | self.transport = transport 9 | 10 | def __call__(self, request: httpx.Request) -> httpx.Response: 11 | if not isinstance( 12 | request.stream, 13 | httpx.SyncByteStream, 14 | ): # pragma: nocover 15 | raise RuntimeError("Attempted to route an async request to a sync app.") 16 | 17 | return self.transport.handle_request(request) 18 | 19 | 20 | class AsyncTransportHandler: 21 | def __init__(self, transport: httpx.AsyncBaseTransport) -> None: 22 | self.transport = transport 23 | 24 | async def __call__(self, request: httpx.Request) -> httpx.Response: 25 | if not isinstance( 26 | request.stream, 27 | httpx.AsyncByteStream, 28 | ): # pragma: nocover 29 | raise RuntimeError("Attempted to route a sync request to an async app.") 30 | 31 | return await self.transport.handle_async_request(request) 32 | 33 | 34 | class WSGIHandler(TransportHandler): 35 | def __init__(self, app: Callable, **kwargs: Any) -> None: 36 | super().__init__(httpx.WSGITransport(app=app, **kwargs)) 37 | 38 | 39 | class ASGIHandler(AsyncTransportHandler): 40 | def __init__(self, app: Callable, **kwargs: Any) -> None: 41 | super().__init__(httpx.ASGITransport(app=app, **kwargs)) 42 | -------------------------------------------------------------------------------- /respx/mocks.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from abc import ABC 3 | from types import MappingProxyType 4 | from typing import TYPE_CHECKING, ClassVar, Dict, List, Type 5 | from unittest import mock 6 | 7 | import httpcore 8 | import httpx 9 | 10 | from respx.patterns import parse_url 11 | 12 | from .models import AllMockedAssertionError, PassThrough 13 | from .transports import TryTransport 14 | 15 | if TYPE_CHECKING: 16 | from .router import Router # pragma: nocover 17 | 18 | __all__ = ["Mocker", "HTTPCoreMocker"] 19 | 20 | 21 | class Mocker(ABC): 22 | _patches: ClassVar[List[mock._patch]] 23 | name: ClassVar[str] 24 | routers: ClassVar[List["Router"]] 25 | targets: ClassVar[List[str]] 26 | target_methods: ClassVar[List[str]] 27 | 28 | # Automatically register all the subclasses in this dict 29 | __registry: ClassVar[Dict[str, Type["Mocker"]]] = {} 30 | registry = MappingProxyType(__registry) 31 | 32 | def __init_subclass__(cls) -> None: 33 | if not getattr(cls, "name", None) or ABC in cls.__bases__: 34 | return 35 | 36 | if cls.name in cls.__registry: 37 | raise TypeError( 38 | "Subclasses of Mocker must define a unique name. " 39 | f"{cls.name!r} is already defined as {cls.__registry[cls.name]!r}" 40 | ) 41 | 42 | cls.routers = [] 43 | cls._patches = [] 44 | cls.__registry[cls.name] = cls 45 | 46 | @classmethod 47 | def register(cls, router: "Router") -> None: 48 | cls.routers.append(router) 49 | 50 | @classmethod 51 | def unregister(cls, router: "Router") -> bool: 52 | if router in cls.routers: 53 | cls.routers.remove(router) 54 | return True 55 | return False 56 | 57 | @classmethod 58 | def add_targets(cls, *targets: str) -> None: 59 | targets = tuple(filter(lambda t: t not in cls.targets, targets)) 60 | if targets: 61 | cls.targets.extend(targets) 62 | cls.restart() 63 | 64 | @classmethod 65 | def remove_targets(cls, *targets: str) -> None: 66 | targets = tuple(filter(lambda t: t in cls.targets, targets)) 67 | if targets: 68 | for target in targets: 69 | cls.targets.remove(target) 70 | cls.restart() 71 | 72 | @classmethod 73 | def start(cls) -> None: 74 | # Ensure we only patch once! 75 | if cls._patches: 76 | return 77 | 78 | # Start patching target transports 79 | for target in cls.targets: 80 | for method in cls.target_methods: 81 | try: 82 | spec = f"{target}.{method}" 83 | patch = mock.patch(spec, spec=True, new_callable=cls.mock) 84 | patch.start() 85 | cls._patches.append(patch) 86 | except AttributeError: 87 | pass 88 | 89 | @classmethod 90 | def stop(cls, force: bool = False) -> None: 91 | # Ensure we don't stop patching when registered transports exists 92 | if cls.routers and not force: 93 | return 94 | 95 | # Stop patching HTTPX 96 | while cls._patches: 97 | patch = cls._patches.pop() 98 | patch.stop() 99 | 100 | @classmethod 101 | def restart(cls) -> None: 102 | # Only stop and start if started 103 | if cls._patches: # pragma: nocover 104 | cls.stop(force=True) 105 | cls.start() 106 | 107 | @classmethod 108 | def handler(cls, httpx_request): 109 | httpx_response = None 110 | assertion_error = None 111 | for router in cls.routers: 112 | try: 113 | httpx_response = router.handler(httpx_request) 114 | except AllMockedAssertionError as error: 115 | assertion_error = error 116 | continue 117 | else: 118 | break 119 | if assertion_error and not httpx_response: 120 | raise assertion_error 121 | return httpx_response 122 | 123 | @classmethod 124 | async def async_handler(cls, httpx_request): 125 | httpx_response = None 126 | assertion_error = None 127 | for router in cls.routers: 128 | try: 129 | httpx_response = await router.async_handler(httpx_request) 130 | except AllMockedAssertionError as error: 131 | assertion_error = error 132 | continue 133 | else: 134 | break 135 | if assertion_error and not httpx_response: 136 | raise assertion_error 137 | return httpx_response 138 | 139 | @classmethod 140 | def mock(cls, spec): 141 | raise NotImplementedError() # pragma: nocover 142 | 143 | 144 | class HTTPXMocker(Mocker): 145 | name = "httpx" 146 | targets = [ 147 | "httpx._client.Client", 148 | "httpx._client.AsyncClient", 149 | ] 150 | target_methods = ["_transport_for_url"] 151 | 152 | @classmethod 153 | def mock(cls, spec): 154 | def _transport_for_url(self, *args, **kwargs): 155 | handler = ( 156 | cls.async_handler 157 | if inspect.iscoroutinefunction(self.request) 158 | else cls.handler 159 | ) 160 | mock_transport = httpx.MockTransport(handler) 161 | pass_through_transport = spec(self, *args, **kwargs) 162 | transport = TryTransport([mock_transport, pass_through_transport]) 163 | return transport 164 | 165 | return _transport_for_url 166 | 167 | 168 | class AbstractRequestMocker(Mocker): 169 | @classmethod 170 | def mock(cls, spec): 171 | if spec.__name__ not in cls.target_methods: 172 | # Prevent mocking mock 173 | return spec 174 | 175 | argspec = inspect.getfullargspec(spec) 176 | 177 | def mock(self, *args, **kwargs): 178 | kwargs = cls._merge_args_and_kwargs(argspec, args, kwargs) 179 | request = cls.to_httpx_request(**kwargs) 180 | request, kwargs = cls.prepare_sync_request(request, **kwargs) 181 | response = cls._send_sync_request( 182 | request, target_spec=spec, instance=self, **kwargs 183 | ) 184 | return response 185 | 186 | async def amock(self, *args, **kwargs): 187 | kwargs = cls._merge_args_and_kwargs(argspec, args, kwargs) 188 | request = cls.to_httpx_request(**kwargs) 189 | request, kwargs = await cls.prepare_async_request(request, **kwargs) 190 | response = await cls._send_async_request( 191 | request, target_spec=spec, instance=self, **kwargs 192 | ) 193 | return response 194 | 195 | return amock if inspect.iscoroutinefunction(spec) else mock 196 | 197 | @classmethod 198 | def _merge_args_and_kwargs(cls, argspec, args, kwargs): 199 | arg_names = argspec.args[1:] # Omit self 200 | new_kwargs = ( 201 | dict(zip(arg_names[-len(argspec.defaults) :], argspec.defaults)) 202 | if argspec.defaults 203 | else dict() 204 | ) 205 | new_kwargs.update(zip(arg_names, args)) 206 | new_kwargs.update(kwargs) 207 | return new_kwargs 208 | 209 | @classmethod 210 | def _send_sync_request(cls, httpx_request, *, target_spec, instance, **kwargs): 211 | try: 212 | httpx_response = cls.handler(httpx_request) 213 | except PassThrough: 214 | response = target_spec(instance, **kwargs) 215 | else: 216 | response = cls.from_sync_httpx_response(httpx_response, instance, **kwargs) 217 | return response 218 | 219 | @classmethod 220 | async def _send_async_request( 221 | cls, httpx_request, *, target_spec, instance, **kwargs 222 | ): 223 | try: 224 | httpx_response = await cls.async_handler(httpx_request) 225 | except PassThrough: 226 | response = await target_spec(instance, **kwargs) 227 | else: 228 | response = await cls.from_async_httpx_response( 229 | httpx_response, instance, **kwargs 230 | ) 231 | return response 232 | 233 | @classmethod 234 | def prepare_sync_request(cls, httpx_request, **kwargs): 235 | """ 236 | Sync pre-read request body 237 | """ 238 | httpx_request.read() 239 | return httpx_request, kwargs 240 | 241 | @classmethod 242 | async def prepare_async_request(cls, httpx_request, **kwargs): 243 | """ 244 | Async pre-read request body 245 | """ 246 | await httpx_request.aread() 247 | return httpx_request, kwargs 248 | 249 | @classmethod 250 | def to_httpx_request(cls, **kwargs): 251 | raise NotImplementedError() # pragma: nocover 252 | 253 | @classmethod 254 | def from_sync_httpx_response(cls, httpx_response, target, **kwargs): 255 | raise NotImplementedError() # pragma: nocover 256 | 257 | @classmethod 258 | async def from_async_httpx_response(cls, httpx_response, target, **kwargs): 259 | raise NotImplementedError() # pragma: nocover 260 | 261 | 262 | class HTTPCoreMocker(AbstractRequestMocker): 263 | name = "httpcore" 264 | targets = [ 265 | "httpcore._sync.connection.HTTPConnection", 266 | "httpcore._sync.connection_pool.ConnectionPool", 267 | "httpcore._sync.http_proxy.HTTPProxy", 268 | "httpcore._async.connection.AsyncHTTPConnection", 269 | "httpcore._async.connection_pool.AsyncConnectionPool", 270 | "httpcore._async.http_proxy.AsyncHTTPProxy", 271 | ] 272 | target_methods = ["handle_request", "handle_async_request"] 273 | 274 | @classmethod 275 | def prepare_sync_request(cls, httpx_request, **kwargs): 276 | """ 277 | Sync pre-read request body, and update transport request arg. 278 | """ 279 | httpx_request, kwargs = super().prepare_sync_request(httpx_request, **kwargs) 280 | kwargs["request"].stream = httpx_request.stream 281 | return httpx_request, kwargs 282 | 283 | @classmethod 284 | async def prepare_async_request(cls, httpx_request, **kwargs): 285 | """ 286 | Async pre-read request body, and update transport request arg. 287 | """ 288 | httpx_request, kwargs = await super().prepare_async_request( 289 | httpx_request, **kwargs 290 | ) 291 | kwargs["request"].stream = httpx_request.stream 292 | return httpx_request, kwargs 293 | 294 | @classmethod 295 | def to_httpx_request(cls, **kwargs): 296 | """ 297 | Create a `HTTPX` request from transport request arg. 298 | """ 299 | request = kwargs["request"] 300 | method = ( 301 | request.method.decode("ascii") 302 | if isinstance(request.method, bytes) 303 | else request.method 304 | ) 305 | raw_url = ( 306 | request.url.scheme, 307 | request.url.host, 308 | request.url.port, 309 | request.url.target, 310 | ) 311 | return httpx.Request( 312 | method, 313 | parse_url(raw_url), 314 | headers=request.headers, 315 | stream=request.stream, 316 | extensions=request.extensions, 317 | ) 318 | 319 | @classmethod 320 | def from_sync_httpx_response(cls, httpx_response, target, **kwargs): 321 | """ 322 | Create a `httpcore` response from a `HTTPX` response. 323 | """ 324 | return httpcore.Response( 325 | status=httpx_response.status_code, 326 | headers=httpx_response.headers.raw, 327 | content=httpx_response.stream, 328 | extensions=httpx_response.extensions, 329 | ) 330 | 331 | @classmethod 332 | async def from_async_httpx_response(cls, httpx_response, target, **kwargs): 333 | """ 334 | Create a `httpcore` response from a `HTTPX` response. 335 | """ 336 | return cls.from_sync_httpx_response(httpx_response, target, **kwargs) 337 | 338 | 339 | DEFAULT_MOCKER: str = HTTPCoreMocker.name 340 | -------------------------------------------------------------------------------- /respx/models.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import ( 3 | Any, 4 | Dict, 5 | Iterator, 6 | List, 7 | NamedTuple, 8 | Optional, 9 | Sequence, 10 | Tuple, 11 | Type, 12 | Union, 13 | ) 14 | from unittest import mock 15 | from warnings import warn 16 | 17 | import httpx 18 | 19 | from respx.utils import SetCookie 20 | 21 | from .patterns import M, Pattern 22 | from .types import ( 23 | CallableSideEffect, 24 | Content, 25 | CookieTypes, 26 | HeaderTypes, 27 | ResolvedResponseTypes, 28 | RouteResultTypes, 29 | SideEffectListTypes, 30 | SideEffectTypes, 31 | ) 32 | 33 | 34 | def clone_response(response: httpx.Response, request: httpx.Request) -> httpx.Response: 35 | """ 36 | Clones a httpx Response for given request. 37 | """ 38 | response = httpx.Response( 39 | response.status_code, 40 | headers=response.headers, 41 | stream=response.stream, 42 | request=request, 43 | extensions=dict(response.extensions), 44 | ) 45 | return response 46 | 47 | 48 | class Call(NamedTuple): 49 | request: httpx.Request 50 | optional_response: Optional[httpx.Response] 51 | 52 | @property 53 | def response(self) -> httpx.Response: 54 | if self.optional_response is None: 55 | raise ValueError(f"{self!r} has no response") 56 | return self.optional_response 57 | 58 | @property 59 | def has_response(self) -> bool: 60 | return self.optional_response is not None 61 | 62 | 63 | class CallList(list, mock.NonCallableMock): 64 | def __init__(self, *args: Sequence[Call], name: Any = "respx") -> None: 65 | super().__init__(*args) 66 | mock.NonCallableMock.__init__(self, name=name) 67 | 68 | @property 69 | def called(self) -> bool: # type: ignore[override] 70 | return bool(self) 71 | 72 | @property 73 | def call_count(self) -> int: # type: ignore[override] 74 | return len(self) 75 | 76 | @property 77 | def last(self) -> Call: 78 | return self[-1] 79 | 80 | def record( 81 | self, request: httpx.Request, response: Optional[httpx.Response] 82 | ) -> Call: 83 | call = Call(request=request, optional_response=response) 84 | self.append(call) 85 | return call 86 | 87 | 88 | class MockResponse(httpx.Response): 89 | def __init__( 90 | self, 91 | status_code: Optional[int] = None, 92 | *, 93 | content: Optional[Content] = None, 94 | content_type: Optional[str] = None, 95 | http_version: Optional[str] = None, 96 | cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None, 97 | **kwargs: Any, 98 | ) -> None: 99 | if not isinstance(content, (str, bytes)) and ( 100 | callable(content) or isinstance(content, (dict, Exception)) 101 | ): 102 | raise TypeError( 103 | f"MockResponse content can only be str, bytes or byte stream" 104 | f"got {content!r}. Please use json=... or side effects." 105 | ) 106 | 107 | if content is not None: 108 | kwargs["content"] = content 109 | if http_version: 110 | kwargs["extensions"] = kwargs.get("extensions", {}) 111 | kwargs["extensions"]["http_version"] = http_version.encode("ascii") 112 | super().__init__(status_code or 200, **kwargs) 113 | 114 | if content_type: 115 | self.headers["Content-Type"] = content_type 116 | 117 | if cookies: 118 | if isinstance(cookies, dict): 119 | cookies = tuple(cookies.items()) 120 | self.headers = httpx.Headers( 121 | ( 122 | *self.headers.multi_items(), 123 | *( 124 | cookie if isinstance(cookie, SetCookie) else SetCookie(*cookie) 125 | for cookie in cookies 126 | ), 127 | ) 128 | ) 129 | 130 | 131 | class Route: 132 | def __init__( 133 | self, 134 | *patterns: Pattern, 135 | **lookups: Any, 136 | ) -> None: 137 | self._pattern = M(*patterns, **lookups) 138 | self._return_value: Optional[httpx.Response] = None 139 | self._side_effect: Optional[SideEffectTypes] = None 140 | self._pass_through: bool = False 141 | self._name: Optional[str] = None 142 | self._snapshots: List[Tuple] = [] 143 | self.calls = CallList(name=self) 144 | self.snapshot() 145 | 146 | def __eq__(self, other: object) -> bool: 147 | if not isinstance(other, Route): 148 | return False # pragma: nocover 149 | return self.pattern == other.pattern 150 | 151 | def __repr__(self): # pragma: nocover 152 | name = f"name={self._name!r} " if self._name else "" 153 | return f"" 154 | 155 | def __call__(self, side_effect: CallableSideEffect) -> CallableSideEffect: 156 | self.side_effect = side_effect 157 | return side_effect 158 | 159 | def __mod__(self, response: Union[int, Dict[str, Any], httpx.Response]) -> "Route": 160 | if isinstance(response, int): 161 | self.return_value = httpx.Response(status_code=response) 162 | 163 | elif isinstance(response, dict): 164 | response.setdefault("status_code", 200) 165 | self.return_value = httpx.Response(**response) 166 | 167 | elif isinstance(response, httpx.Response): 168 | self.return_value = response 169 | 170 | else: 171 | raise TypeError( 172 | f"Route can only % with int, dict or Response, got {response!r}" 173 | ) 174 | 175 | return self 176 | 177 | @property 178 | def name(self) -> Optional[str]: 179 | return self._name 180 | 181 | @name.setter 182 | def name(self, name: str) -> None: 183 | raise NotImplementedError("Can't set name on route.") 184 | 185 | @property 186 | def pattern(self) -> Pattern: 187 | return self._pattern 188 | 189 | @pattern.setter 190 | def pattern(self, pattern: Pattern) -> None: 191 | raise NotImplementedError("Can't change route pattern.") 192 | 193 | @property 194 | def return_value(self) -> Optional[httpx.Response]: 195 | return self._return_value 196 | 197 | @return_value.setter 198 | def return_value(self, return_value: Optional[httpx.Response]) -> None: 199 | if return_value is not None and not isinstance(return_value, httpx.Response): 200 | raise TypeError(f"{return_value!r} is not an instance of httpx.Response") 201 | self.pass_through(False) 202 | self._return_value = return_value 203 | 204 | @property 205 | def side_effect( 206 | self, 207 | ) -> Optional[Union[SideEffectTypes, Sequence[SideEffectListTypes]]]: 208 | return self._side_effect 209 | 210 | @side_effect.setter 211 | def side_effect( 212 | self, 213 | side_effect: Optional[Union[SideEffectTypes, Sequence[SideEffectListTypes]]], 214 | ) -> None: 215 | self.pass_through(False) 216 | if not side_effect: 217 | self._side_effect = None 218 | elif isinstance(side_effect, (Iterator, Sequence)): 219 | self._side_effect = iter(side_effect) 220 | else: 221 | self._side_effect = side_effect 222 | 223 | def snapshot(self) -> None: 224 | # Clone iterator-type side effect to not get pre-exhausted when rolled back 225 | side_effect = self._side_effect 226 | if isinstance(side_effect, Iterator): 227 | side_effects = tuple(side_effect) 228 | self._side_effect = iter(side_effects) 229 | side_effect = iter(side_effects) 230 | 231 | self._snapshots.append( 232 | ( 233 | self._pattern, 234 | self._name, 235 | self._return_value, 236 | side_effect, 237 | self._pass_through, 238 | CallList(self.calls, name=self), 239 | ), 240 | ) 241 | 242 | def rollback(self) -> None: 243 | if not self._snapshots: 244 | return 245 | 246 | snapshot = self._snapshots.pop() 247 | pattern, name, return_value, side_effect, pass_through, calls = snapshot 248 | 249 | self._pattern = pattern 250 | self._name = name 251 | self._return_value = return_value 252 | self._side_effect = side_effect 253 | self.pass_through(pass_through) 254 | self.calls[:] = calls 255 | 256 | def reset(self) -> None: 257 | self.calls.clear() 258 | 259 | def mock( 260 | self, 261 | return_value: Optional[httpx.Response] = None, 262 | *, 263 | side_effect: Optional[ 264 | Union[SideEffectTypes, Sequence[SideEffectListTypes]] 265 | ] = None, 266 | ) -> "Route": 267 | self.return_value = return_value 268 | self.side_effect = side_effect 269 | return self 270 | 271 | def respond( 272 | self, 273 | status_code: int = 200, 274 | *, 275 | headers: Optional[HeaderTypes] = None, 276 | cookies: Optional[Union[CookieTypes, Sequence[SetCookie]]] = None, 277 | content: Optional[Content] = None, 278 | text: Optional[str] = None, 279 | html: Optional[str] = None, 280 | json: Optional[Union[str, List, Dict]] = None, 281 | stream: Optional[Union[httpx.SyncByteStream, httpx.AsyncByteStream]] = None, 282 | content_type: Optional[str] = None, 283 | http_version: Optional[str] = None, 284 | **kwargs: Any, 285 | ) -> "Route": 286 | response = MockResponse( 287 | status_code, 288 | headers=headers, 289 | cookies=cookies, 290 | content=content, 291 | text=text, 292 | html=html, 293 | json=json, 294 | stream=stream, 295 | content_type=content_type, 296 | http_version=http_version, 297 | **kwargs, 298 | ) 299 | return self.mock(return_value=response) 300 | 301 | def pass_through(self, value: bool = True) -> "Route": 302 | self._pass_through = value 303 | return self 304 | 305 | @property 306 | def is_pass_through(self) -> bool: 307 | return self._pass_through 308 | 309 | @property 310 | def called(self) -> bool: 311 | return self.calls.called 312 | 313 | @property 314 | def call_count(self) -> int: 315 | return self.calls.call_count 316 | 317 | def _next_side_effect( 318 | self, 319 | ) -> Union[CallableSideEffect, Exception, Type[Exception], httpx.Response]: 320 | assert self._side_effect is not None 321 | effect: Union[CallableSideEffect, Exception, Type[Exception], httpx.Response] 322 | if isinstance(self._side_effect, Iterator): 323 | effect = next(self._side_effect) 324 | else: 325 | effect = self._side_effect 326 | 327 | return effect 328 | 329 | def _call_side_effect( 330 | self, effect: CallableSideEffect, request: httpx.Request, **kwargs: Any 331 | ) -> RouteResultTypes: 332 | # Add route kwarg if the side effect wants it 333 | argspec = inspect.getfullargspec(effect) 334 | if "route" in kwargs: 335 | warn(f"Matched context contains reserved word `route`: {self.pattern!r}") 336 | if "route" in argspec.args: 337 | kwargs["route"] = self 338 | 339 | try: 340 | # Call side effect 341 | result: RouteResultTypes = effect(request, **kwargs) 342 | except Exception as error: 343 | raise SideEffectError(self, origin=error) from error 344 | 345 | # Validate result 346 | if ( 347 | result 348 | and not inspect.isawaitable(result) 349 | and not isinstance(result, (httpx.Response, httpx.Request)) 350 | ): 351 | raise TypeError( 352 | f"Side effects must return; either a `httpx.Response`," 353 | f"a `httpx.Request` for pass-through, " 354 | f"or `None` for a non-match. Got {result!r}" 355 | ) 356 | 357 | return result 358 | 359 | def _resolve_side_effect( 360 | self, request: httpx.Request, **kwargs: Any 361 | ) -> RouteResultTypes: 362 | effect = self._next_side_effect() 363 | 364 | # Handle Exception `instance` side effect 365 | if isinstance(effect, Exception): 366 | raise SideEffectError(self, origin=effect) 367 | 368 | # Handle Exception `type` side effect 369 | elif isinstance(effect, type): 370 | assert issubclass(effect, Exception) 371 | raise SideEffectError( 372 | self, 373 | origin=( 374 | effect("Mock Error", request=request) 375 | if issubclass(effect, httpx.RequestError) 376 | else effect() 377 | ), 378 | ) 379 | 380 | # Handle `Callable` side effect 381 | elif callable(effect): 382 | result = self._call_side_effect(effect, request, **kwargs) 383 | return result 384 | 385 | # Resolved effect is a mocked response 386 | return effect 387 | 388 | def resolve(self, request: httpx.Request, **kwargs: Any) -> RouteResultTypes: 389 | result: RouteResultTypes = None 390 | 391 | if self._side_effect: 392 | result = self._resolve_side_effect(request, **kwargs) 393 | if result is None: 394 | return None # Side effect resolved as a non-matching route 395 | 396 | elif self._return_value: 397 | result = self._return_value 398 | 399 | else: 400 | # Auto mock a new response 401 | result = httpx.Response(200, request=request) 402 | 403 | if isinstance(result, httpx.Response) and not result._request: 404 | # Clone reused Response for immutability 405 | result = clone_response(result, request) 406 | 407 | return result 408 | 409 | def match(self, request: httpx.Request) -> RouteResultTypes: 410 | """ 411 | Matches and resolves request with given patterns and optional side effect. 412 | 413 | Returns None for a non-matching route, mocked response for a match, 414 | or input request for pass-through. 415 | """ 416 | context: Dict[str, Any] = {} 417 | 418 | if self._pattern: 419 | match = self._pattern.match(request) 420 | if not match: 421 | return None 422 | context = match.context 423 | 424 | if self._pass_through: 425 | return request 426 | 427 | result = self.resolve(request, **context) 428 | return result 429 | 430 | 431 | class RouteList: 432 | _routes: List[Route] 433 | _names: Dict[str, Route] 434 | 435 | def __init__(self, routes: Optional["RouteList"] = None) -> None: 436 | if routes is None: 437 | self._routes = [] 438 | self._names = {} 439 | else: 440 | self._routes = list(routes._routes) 441 | self._names = dict(routes._names) 442 | 443 | def __repr__(self) -> str: 444 | return repr(self._routes) # pragma: nocover 445 | 446 | def __iter__(self) -> Iterator[Route]: 447 | return iter(self._routes) 448 | 449 | def __bool__(self) -> bool: 450 | return bool(self._routes) 451 | 452 | def __len__(self) -> int: 453 | return len(self._routes) 454 | 455 | def __contains__(self, name: str) -> bool: 456 | return name in self._names 457 | 458 | def __getitem__(self, key: Union[int, str]) -> Route: 459 | if isinstance(key, int): 460 | return self._routes[key] 461 | else: 462 | return self._names[key] 463 | 464 | def __setitem__(self, i: slice, routes: "RouteList") -> None: 465 | """ 466 | Re-set all routes to given routes. 467 | """ 468 | if (i.start, i.stop, i.step) != (None, None, None): 469 | raise TypeError("Can't slice assign routes") 470 | self._routes = list(routes._routes) 471 | self._names = dict(routes._names) 472 | 473 | def clear(self) -> None: 474 | self._routes.clear() 475 | self._names.clear() 476 | 477 | def add(self, route: Route, name: Optional[str] = None) -> Route: 478 | # Find route with same name 479 | existing_route = self._names.pop(name or "", None) 480 | 481 | if route in self._routes: 482 | if existing_route and existing_route != route: 483 | # Re-use existing route with same name, and drop any with same pattern 484 | index = self._routes.index(route) 485 | same_pattern_route = self._routes.pop(index) 486 | if same_pattern_route.name: 487 | del self._names[same_pattern_route.name] 488 | same_pattern_route._name = None 489 | elif not existing_route: 490 | # Re-use existing route with same pattern 491 | index = self._routes.index(route) 492 | existing_route = self._routes[index] 493 | if existing_route.name: 494 | del self._names[existing_route.name] 495 | existing_route._name = None 496 | 497 | if existing_route: 498 | # Update existing route's pattern and mock 499 | existing_route._pattern = route._pattern 500 | existing_route.return_value = route.return_value 501 | existing_route.side_effect = route.side_effect 502 | existing_route.pass_through(route.is_pass_through) 503 | route = existing_route 504 | else: 505 | # Add new route 506 | self._routes.append(route) 507 | 508 | if name: 509 | route._name = name 510 | self._names[name] = route 511 | 512 | return route 513 | 514 | def pop(self, name, default=...): 515 | """ 516 | Removes a route by name and returns it. 517 | 518 | Raises KeyError when `default` not provided and name is not found. 519 | """ 520 | try: 521 | route = self._names.pop(name) 522 | self._routes.remove(route) 523 | return route 524 | except KeyError as ex: 525 | if default is ...: 526 | raise ex 527 | return default 528 | 529 | 530 | class AllMockedAssertionError(AssertionError): 531 | pass 532 | 533 | 534 | class SideEffectError(Exception): 535 | def __init__(self, route: Route, origin: Exception) -> None: 536 | self.route = route 537 | self.origin = origin 538 | 539 | 540 | class PassThrough(Exception): 541 | def __init__(self, message: str, *, request: httpx.Request, origin: Route) -> None: 542 | super().__init__(message) 543 | self.request = request 544 | self.origin = origin 545 | 546 | 547 | class ResolvedRoute: 548 | def __init__(self): 549 | self.route: Optional[Route] = None 550 | self.response: Optional[ResolvedResponseTypes] = None 551 | -------------------------------------------------------------------------------- /respx/plugin.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | import pytest 4 | 5 | import respx 6 | 7 | from .router import MockRouter 8 | 9 | 10 | def pytest_configure(config): 11 | config.addinivalue_line( 12 | "markers", 13 | "respx(assert_all_called=False, assert_all_mocked=False, base_url=...): " 14 | "configure the respx_mock fixture. " 15 | "See https://lundberg.github.io/respx/api.html#configuration", 16 | ) 17 | 18 | 19 | @pytest.fixture 20 | def respx_mock(request): 21 | respx_marker = request.node.get_closest_marker("respx") 22 | 23 | mock_router: MockRouter = ( 24 | respx.mock 25 | if respx_marker is None 26 | else cast(MockRouter, respx.mock(**respx_marker.kwargs)) 27 | ) 28 | 29 | with mock_router: 30 | yield mock_router 31 | -------------------------------------------------------------------------------- /respx/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lundberg/respx/40b42ceac07477ca3ab8abbb9bdaf8ada76775e9/respx/py.typed -------------------------------------------------------------------------------- /respx/router.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from contextlib import contextmanager 3 | from functools import partial, update_wrapper, wraps 4 | from types import TracebackType 5 | from typing import ( 6 | Any, 7 | Callable, 8 | Dict, 9 | Generator, 10 | List, 11 | NewType, 12 | Optional, 13 | Tuple, 14 | Type, 15 | Union, 16 | cast, 17 | overload, 18 | ) 19 | 20 | import httpx 21 | 22 | from .mocks import Mocker 23 | from .models import ( 24 | AllMockedAssertionError, 25 | CallList, 26 | PassThrough, 27 | ResolvedRoute, 28 | Route, 29 | RouteList, 30 | SideEffectError, 31 | ) 32 | from .patterns import Pattern, merge_patterns, parse_url_patterns 33 | from .types import DefaultType, ResolvedResponseTypes, RouteResultTypes, URLPatternTypes 34 | 35 | Default = NewType("Default", object) 36 | DEFAULT = Default(...) 37 | 38 | 39 | class Router: 40 | def __init__( 41 | self, 42 | *, 43 | assert_all_called: bool = True, 44 | assert_all_mocked: bool = True, 45 | base_url: Optional[str] = None, 46 | ) -> None: 47 | self._assert_all_called = assert_all_called 48 | self._assert_all_mocked = assert_all_mocked 49 | self._bases = parse_url_patterns(base_url, exact=False) 50 | 51 | self.routes = RouteList() 52 | self.calls = CallList() 53 | 54 | self._snapshots: List[Tuple] = [] 55 | self.snapshot() 56 | 57 | def clear(self) -> None: 58 | """ 59 | Clears all routes. May be rolled back to snapshot state. 60 | """ 61 | self.routes.clear() 62 | 63 | def snapshot(self) -> None: 64 | """ 65 | Snapshots current routes and calls state. 66 | """ 67 | # Snapshot current routes and calls 68 | routes = RouteList(self.routes) 69 | calls = CallList(self.calls) 70 | self._snapshots.append((routes, calls)) 71 | 72 | # Snapshot each route state 73 | for route in routes: 74 | route.snapshot() 75 | 76 | def rollback(self) -> None: 77 | """ 78 | Rollbacks routes, and optionally calls, to snapshot state. 79 | """ 80 | if not self._snapshots: 81 | return 82 | 83 | # Revert added routes and calls to last snapshot 84 | routes, calls = self._snapshots.pop() 85 | self.routes[:] = routes 86 | self.calls[:] = calls 87 | 88 | # Revert each route state to last snapshot 89 | for route in self.routes: 90 | route.rollback() 91 | 92 | def reset(self) -> None: 93 | """ 94 | Resets call stats. 95 | """ 96 | self.calls.clear() 97 | for route in self.routes: 98 | route.reset() 99 | 100 | def assert_all_called(self) -> None: 101 | not_called_routes = [route for route in self.routes if not route.called] 102 | assert not_called_routes == [], "RESPX: some routes were not called!" 103 | 104 | def __getitem__(self, name: str) -> Route: 105 | return self.routes[name] 106 | 107 | @overload 108 | def pop(self, name: str) -> Route: 109 | ... # pragma: nocover 110 | 111 | @overload 112 | def pop(self, name: str, default: DefaultType) -> Union[Route, DefaultType]: 113 | ... # pragma: nocover 114 | 115 | def pop(self, name, default=...): 116 | """ 117 | Removes a route by name and returns it. 118 | 119 | Raises KeyError when `default` not provided and name is not found. 120 | """ 121 | try: 122 | return self.routes.pop(name) 123 | except KeyError as ex: 124 | if default is ...: 125 | raise ex 126 | return default 127 | 128 | def route( 129 | self, *patterns: Pattern, name: Optional[str] = None, **lookups: Any 130 | ) -> Route: 131 | route = Route(*patterns, **lookups) 132 | return self.add(route, name=name) 133 | 134 | def add(self, route: Route, *, name: Optional[str] = None) -> Route: 135 | """ 136 | Adds a route with optionally given name, 137 | replacing any existing route with same name or pattern. 138 | """ 139 | if not isinstance(route, Route): 140 | raise ValueError( 141 | f"Invalid route {route!r}, please use respx.route(...).mock(...)" 142 | ) 143 | 144 | route._pattern = merge_patterns(route.pattern, **self._bases) 145 | route = self.routes.add(route, name=name) 146 | return route 147 | 148 | def request( 149 | self, 150 | method: str, 151 | url: Optional[URLPatternTypes] = None, 152 | *, 153 | name: Optional[str] = None, 154 | **lookups: Any, 155 | ) -> Route: 156 | if lookups: 157 | # Validate that lookups doesn't contain method or url 158 | pattern_keys = {p.split("__", 1)[0] for p in lookups.keys()} 159 | if "method" in pattern_keys: 160 | raise TypeError("Got multiple values for pattern 'method'") 161 | elif url and "url" in pattern_keys: 162 | raise TypeError("Got multiple values for pattern 'url'") 163 | 164 | return self.route(method=method, url=url, name=name, **lookups) 165 | 166 | def get( 167 | self, 168 | url: Optional[URLPatternTypes] = None, 169 | *, 170 | name: Optional[str] = None, 171 | **lookups: Any, 172 | ) -> Route: 173 | return self.request(method="GET", url=url, name=name, **lookups) 174 | 175 | def post( 176 | self, 177 | url: Optional[URLPatternTypes] = None, 178 | *, 179 | name: Optional[str] = None, 180 | **lookups: Any, 181 | ) -> Route: 182 | return self.request(method="POST", url=url, name=name, **lookups) 183 | 184 | def put( 185 | self, 186 | url: Optional[URLPatternTypes] = None, 187 | *, 188 | name: Optional[str] = None, 189 | **lookups: Any, 190 | ) -> Route: 191 | return self.request(method="PUT", url=url, name=name, **lookups) 192 | 193 | def patch( 194 | self, 195 | url: Optional[URLPatternTypes] = None, 196 | *, 197 | name: Optional[str] = None, 198 | **lookups: Any, 199 | ) -> Route: 200 | return self.request(method="PATCH", url=url, name=name, **lookups) 201 | 202 | def delete( 203 | self, 204 | url: Optional[URLPatternTypes] = None, 205 | *, 206 | name: Optional[str] = None, 207 | **lookups: Any, 208 | ) -> Route: 209 | return self.request(method="DELETE", url=url, name=name, **lookups) 210 | 211 | def head( 212 | self, 213 | url: Optional[URLPatternTypes] = None, 214 | *, 215 | name: Optional[str] = None, 216 | **lookups: Any, 217 | ) -> Route: 218 | return self.request(method="HEAD", url=url, name=name, **lookups) 219 | 220 | def options( 221 | self, 222 | url: Optional[URLPatternTypes] = None, 223 | *, 224 | name: Optional[str] = None, 225 | **lookups: Any, 226 | ) -> Route: 227 | return self.request(method="OPTIONS", url=url, name=name, **lookups) 228 | 229 | def record( 230 | self, 231 | request: httpx.Request, 232 | *, 233 | response: Optional[httpx.Response] = None, 234 | route: Optional[Route] = None, 235 | ) -> None: 236 | call = self.calls.record(request, response) 237 | if route: 238 | route.calls.append(call) 239 | 240 | @contextmanager 241 | def resolver(self, request: httpx.Request) -> Generator[ResolvedRoute, None, None]: 242 | resolved = ResolvedRoute() 243 | 244 | try: 245 | yield resolved 246 | 247 | if resolved.route is None: 248 | # Assert we always get a route match, if check is enabled 249 | if self._assert_all_mocked: 250 | raise AllMockedAssertionError(f"RESPX: {request!r} not mocked!") 251 | 252 | # Auto mock a successful empty response 253 | resolved.response = httpx.Response(200) 254 | 255 | elif resolved.response == request: 256 | # Pass-through request 257 | raise PassThrough( 258 | f"Request marked to pass through: {request!r}", 259 | request=request, 260 | origin=resolved.route, 261 | ) 262 | 263 | else: 264 | # Mocked response 265 | assert isinstance(resolved.response, httpx.Response) 266 | 267 | except SideEffectError as error: 268 | self.record(request, response=None, route=error.route) 269 | raise error.origin from error 270 | except PassThrough: 271 | self.record(request, response=None, route=resolved.route) 272 | raise 273 | else: 274 | self.record(request, response=resolved.response, route=resolved.route) 275 | 276 | def resolve(self, request: httpx.Request) -> ResolvedRoute: 277 | with self.resolver(request) as resolved: 278 | for route in self.routes: 279 | prospect = route.match(request) 280 | if prospect is not None: 281 | resolved.route = route 282 | resolved.response = cast(ResolvedResponseTypes, prospect) 283 | break 284 | 285 | if resolved.response and isinstance(resolved.response.stream, httpx.ByteStream): 286 | resolved.response.read() # Pre-read stream 287 | 288 | return resolved 289 | 290 | async def aresolve(self, request: httpx.Request) -> ResolvedRoute: 291 | with self.resolver(request) as resolved: 292 | for route in self.routes: 293 | prospect: RouteResultTypes = route.match(request) 294 | 295 | # Await async side effect and wrap any exception 296 | if inspect.isawaitable(prospect): 297 | try: 298 | prospect = await prospect 299 | except Exception as error: 300 | raise SideEffectError(route, origin=error) from error 301 | 302 | if prospect is not None: 303 | resolved.route = route 304 | resolved.response = cast(ResolvedResponseTypes, prospect) 305 | break 306 | 307 | if resolved.response and isinstance(resolved.response.stream, httpx.ByteStream): 308 | await resolved.response.aread() # Pre-read stream 309 | 310 | return resolved 311 | 312 | def handler(self, request: httpx.Request) -> httpx.Response: 313 | resolved = self.resolve(request) 314 | assert isinstance(resolved.response, httpx.Response) 315 | return resolved.response 316 | 317 | async def async_handler(self, request: httpx.Request) -> httpx.Response: 318 | resolved = await self.aresolve(request) 319 | assert isinstance(resolved.response, httpx.Response) 320 | return resolved.response 321 | 322 | 323 | class MockRouter(Router): 324 | def __init__( 325 | self, 326 | *, 327 | assert_all_called: bool = True, 328 | assert_all_mocked: bool = True, 329 | base_url: Optional[str] = None, 330 | using: Optional[Union[str, Default]] = DEFAULT, 331 | ) -> None: 332 | super().__init__( 333 | assert_all_called=assert_all_called, 334 | assert_all_mocked=assert_all_mocked, 335 | base_url=base_url, 336 | ) 337 | self.Mocker: Optional[Type[Mocker]] = None 338 | self._using = using 339 | 340 | @overload 341 | def __call__( 342 | self, 343 | func: None = None, 344 | *, 345 | assert_all_called: Optional[bool] = None, 346 | assert_all_mocked: Optional[bool] = None, 347 | base_url: Optional[str] = None, 348 | using: Optional[Union[str, Default]] = DEFAULT, 349 | ) -> "MockRouter": 350 | ... # pragma: nocover 351 | 352 | @overload 353 | def __call__( 354 | self, 355 | func: Callable = ..., 356 | *, 357 | assert_all_called: Optional[bool] = None, 358 | assert_all_mocked: Optional[bool] = None, 359 | base_url: Optional[str] = None, 360 | using: Optional[Union[str, Default]] = DEFAULT, 361 | ) -> Callable: 362 | ... # pragma: nocover 363 | 364 | def __call__( 365 | self, 366 | func: Optional[Callable] = None, 367 | *, 368 | assert_all_called: Optional[bool] = None, 369 | assert_all_mocked: Optional[bool] = None, 370 | base_url: Optional[str] = None, 371 | using: Optional[Union[str, Default]] = DEFAULT, 372 | ) -> Union["MockRouter", Callable]: 373 | """ 374 | Decorator or Context Manager. 375 | 376 | Use decorator/manager with parentheses for local state, or without parentheses 377 | for global state, i.e. shared patterns added outside of scope. 378 | """ 379 | if func is None: 380 | # Parentheses used, branch out to new nested instance. 381 | # - Only stage when using local ctx `with respx.mock(...) as respx_mock:` 382 | # - First stage when using local decorator `@respx.mock(...)` 383 | # FYI, global ctx `with respx.mock:` hits __enter__ directly 384 | settings: Dict[str, Any] = { 385 | "base_url": base_url, 386 | "using": using, 387 | } 388 | if assert_all_called is not None: 389 | settings["assert_all_called"] = assert_all_called 390 | if assert_all_mocked is not None: 391 | settings["assert_all_mocked"] = assert_all_mocked 392 | respx_mock = self.__class__(**settings) 393 | return respx_mock 394 | 395 | # Determine if decorated function needs a `respx_mock` instance 396 | is_async = inspect.iscoroutinefunction(func) 397 | argspec = inspect.getfullargspec(func) 398 | needs_mock_reference = "respx_mock" in argspec.args 399 | 400 | if needs_mock_reference: 401 | func = partial(func, respx_mock=self) 402 | 403 | # Async Decorator 404 | async def _async_decorator(*args, **kwargs): 405 | assert func is not None 406 | async with self: 407 | return await func(*args, **kwargs) 408 | 409 | # Sync Decorator 410 | def _sync_decorator(*args, **kwargs): 411 | assert func is not None 412 | with self: 413 | return func(*args, **kwargs) 414 | 415 | if needs_mock_reference: 416 | async_decorator = wraps(func)(_async_decorator) 417 | sync_decorator = wraps(func)(_sync_decorator) 418 | else: 419 | async_decorator = update_wrapper(_async_decorator, func) 420 | sync_decorator = update_wrapper(_sync_decorator, func) 421 | 422 | # Dispatch async/sync decorator, depending on decorated function. 423 | # - Only stage when using global decorator `@respx.mock` 424 | # - Second stage when using local decorator `@respx.mock(...)` 425 | return async_decorator if is_async else sync_decorator 426 | 427 | def __enter__(self) -> "MockRouter": 428 | self.start() 429 | return self 430 | 431 | def __exit__( 432 | self, 433 | exc_type: Optional[Type[BaseException]] = None, 434 | exc_value: Optional[BaseException] = None, 435 | traceback: Optional[TracebackType] = None, 436 | ) -> None: 437 | self.stop(quiet=bool(exc_type is not None)) 438 | 439 | async def __aenter__(self) -> "MockRouter": 440 | return self.__enter__() 441 | 442 | async def __aexit__(self, *args: Any) -> None: 443 | self.__exit__(*args) 444 | 445 | @property 446 | def using(self) -> Optional[str]: 447 | from respx.mocks import DEFAULT_MOCKER 448 | 449 | if self._using is None: 450 | using = None 451 | elif self._using is DEFAULT: 452 | using = DEFAULT_MOCKER 453 | elif isinstance(self._using, str): 454 | using = self._using 455 | else: 456 | raise ValueError(f"Invalid Router `using` kwarg: {self._using!r}") 457 | 458 | return using 459 | 460 | def start(self) -> None: 461 | """ 462 | Register transport, snapshot router and start patching. 463 | """ 464 | self.snapshot() 465 | self.Mocker = Mocker.registry.get(self.using or "") 466 | if self.Mocker: 467 | self.Mocker.register(self) 468 | self.Mocker.start() 469 | 470 | def stop(self, clear: bool = True, reset: bool = True, quiet: bool = False) -> None: 471 | """ 472 | Unregister transport and rollback router. 473 | Stop patching when no registered transports left. 474 | """ 475 | unregistered = self.Mocker.unregister(self) if self.Mocker else True 476 | 477 | try: 478 | if unregistered and not quiet and self._assert_all_called: 479 | self.assert_all_called() 480 | finally: 481 | if clear: 482 | self.rollback() 483 | if reset: 484 | self.reset() 485 | if self.Mocker: 486 | self.Mocker.stop() 487 | -------------------------------------------------------------------------------- /respx/transports.py: -------------------------------------------------------------------------------- 1 | from types import TracebackType 2 | from typing import ( 3 | TYPE_CHECKING, 4 | Any, 5 | Callable, 6 | Coroutine, 7 | List, 8 | Optional, 9 | Type, 10 | Union, 11 | cast, 12 | ) 13 | from warnings import warn 14 | 15 | import httpx 16 | from httpx import AsyncBaseTransport, BaseTransport 17 | 18 | from .models import PassThrough 19 | 20 | if TYPE_CHECKING: 21 | from .router import Router # pragma: nocover 22 | 23 | RequestHandler = Callable[[httpx.Request], httpx.Response] 24 | AsyncRequestHandler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]] 25 | 26 | 27 | class MockTransport(httpx.MockTransport): 28 | _router: Optional["Router"] 29 | 30 | def __init__( 31 | self, 32 | *, 33 | handler: Optional[RequestHandler] = None, 34 | async_handler: Optional[AsyncRequestHandler] = None, 35 | router: Optional["Router"] = None, 36 | ): 37 | if router: 38 | super().__init__(router.handler) 39 | self._router = router 40 | elif handler: 41 | super().__init__(handler) 42 | self._router = None 43 | elif async_handler: 44 | super().__init__(async_handler) 45 | self._router = None 46 | else: 47 | raise RuntimeError( 48 | "Missing a MockTransport required handler or router argument" 49 | ) 50 | warn( 51 | "MockTransport is deprecated. " 52 | "Please use `httpx.MockTransport(respx_router.handler)`.", 53 | category=DeprecationWarning, 54 | ) 55 | 56 | def __exit__( 57 | self, 58 | exc_type: Optional[Type[BaseException]] = None, 59 | exc_value: Optional[BaseException] = None, 60 | traceback: Optional[TracebackType] = None, 61 | ) -> None: 62 | if not exc_type and self._router and self._router._assert_all_called: 63 | self._router.assert_all_called() 64 | 65 | async def __aexit__(self, *args: Any) -> None: 66 | self.__exit__(*args) 67 | 68 | 69 | class TryTransport(BaseTransport, AsyncBaseTransport): 70 | def __init__( 71 | self, transports: List[Union[BaseTransport, AsyncBaseTransport]] 72 | ) -> None: 73 | self.transports = transports 74 | 75 | def handle_request(self, request: httpx.Request) -> httpx.Response: 76 | for transport in self.transports: 77 | try: 78 | transport = cast(BaseTransport, transport) 79 | return transport.handle_request(request) 80 | except PassThrough: 81 | continue 82 | 83 | raise RuntimeError() # pragma: nocover 84 | 85 | async def handle_async_request(self, request: httpx.Request) -> httpx.Response: 86 | for transport in self.transports: 87 | try: 88 | transport = cast(AsyncBaseTransport, transport) 89 | return await transport.handle_async_request(request) 90 | except PassThrough: 91 | continue 92 | 93 | raise RuntimeError() # pragma: nocover 94 | -------------------------------------------------------------------------------- /respx/types.py: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | IO, 3 | Any, 4 | AsyncIterable, 5 | Awaitable, 6 | Callable, 7 | Dict, 8 | Iterable, 9 | Iterator, 10 | List, 11 | Mapping, 12 | Optional, 13 | Pattern, 14 | Sequence, 15 | Tuple, 16 | Type, 17 | TypeVar, 18 | Union, 19 | ) 20 | 21 | import httpx 22 | 23 | URL = Tuple[ 24 | bytes, # scheme 25 | bytes, # host 26 | Optional[int], # port 27 | bytes, # path 28 | ] 29 | Headers = List[Tuple[bytes, bytes]] 30 | Content = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] 31 | 32 | HeaderTypes = Union[ 33 | httpx.Headers, 34 | Dict[str, str], 35 | Dict[bytes, bytes], 36 | Sequence[Tuple[str, str]], 37 | Sequence[Tuple[bytes, bytes]], 38 | ] 39 | CookieTypes = Union[Dict[str, str], Sequence[Tuple[str, str]]] 40 | 41 | DefaultType = TypeVar("DefaultType", bound=Any) 42 | 43 | URLPatternTypes = Union[str, Pattern[str], URL, httpx.URL] 44 | QueryParamTypes = Union[ 45 | bytes, str, List[Tuple[str, Any]], Dict[str, Any], Tuple[Tuple[str, Any], ...] 46 | ] 47 | 48 | ResolvedResponseTypes = Optional[Union[httpx.Request, httpx.Response]] 49 | RouteResultTypes = Union[ResolvedResponseTypes, Awaitable[ResolvedResponseTypes]] 50 | CallableSideEffect = Callable[..., RouteResultTypes] 51 | SideEffectListTypes = Union[httpx.Response, Exception, Type[Exception]] 52 | SideEffectTypes = Union[ 53 | CallableSideEffect, 54 | Exception, 55 | Type[Exception], 56 | Iterator[SideEffectListTypes], 57 | ] 58 | 59 | # Borrowed from HTTPX's "private" types. 60 | FileContent = Union[IO[bytes], bytes, str] 61 | FileTypes = Union[ 62 | # file (or bytes) 63 | FileContent, 64 | # (filename, file (or bytes)) 65 | Tuple[Optional[str], FileContent], 66 | # (filename, file (or bytes), content_type) 67 | Tuple[Optional[str], FileContent, Optional[str]], 68 | # (filename, file (or bytes), content_type, headers) 69 | Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], 70 | ] 71 | RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] 72 | -------------------------------------------------------------------------------- /respx/utils.py: -------------------------------------------------------------------------------- 1 | import email 2 | from collections import defaultdict 3 | from datetime import datetime 4 | from email.message import Message 5 | from typing import ( 6 | Any, 7 | Dict, 8 | Iterable, 9 | List, 10 | Literal, 11 | NamedTuple, 12 | Optional, 13 | Tuple, 14 | Type, 15 | TypeVar, 16 | Union, 17 | cast, 18 | ) 19 | from urllib.parse import parse_qsl 20 | 21 | import httpx 22 | 23 | 24 | class MultiItems(defaultdict): 25 | def __init__(self, values: Optional[Iterable[Tuple[str, Any]]] = None) -> None: 26 | super().__init__(tuple) 27 | if values is not None: 28 | for key, value in values: 29 | if isinstance(value, (tuple, list)): 30 | self[key] += tuple(value) # Convert list to tuple and extend 31 | else: 32 | self[key] += (value,) # Extend with value 33 | 34 | def get_list(self, key: str) -> List[Any]: 35 | return list(self[key]) 36 | 37 | def multi_items(self) -> List[Tuple[str, str]]: 38 | return [(key, value) for key, values in self.items() for value in values] 39 | 40 | def append(self, key: str, value: Any) -> None: 41 | self[key] += (value,) 42 | 43 | 44 | def _parse_multipart_form_data( 45 | content: bytes, *, content_type: str, encoding: str 46 | ) -> Tuple[MultiItems, MultiItems]: 47 | form_data = b"\r\n".join( 48 | ( 49 | b"MIME-Version: 1.0", 50 | b"Content-Type: " + content_type.encode(encoding), 51 | b"\r\n" + content, 52 | ) 53 | ) 54 | data = MultiItems() 55 | files = MultiItems() 56 | for payload in email.message_from_bytes(form_data).get_payload(): 57 | payload = cast(Message, payload) 58 | name = payload.get_param("name", header="Content-Disposition") 59 | assert isinstance(name, str) 60 | filename = payload.get_filename() 61 | content_type = payload.get_content_type() 62 | value = payload.get_payload(decode=True) 63 | assert isinstance(value, bytes) 64 | if content_type.startswith("text/") and filename is None: 65 | # Text field 66 | data.append(name, value.decode(payload.get_content_charset() or "utf-8")) 67 | else: 68 | # File field 69 | files.append(name, (filename, value)) 70 | 71 | return data, files 72 | 73 | 74 | def _parse_urlencoded_data(content: bytes, *, encoding: str) -> MultiItems: 75 | return MultiItems( 76 | (key, value) 77 | for key, value in parse_qsl(content.decode(encoding), keep_blank_values=True) 78 | ) 79 | 80 | 81 | def decode_data(request: httpx.Request) -> Tuple[MultiItems, MultiItems]: 82 | content = request.read() 83 | content_type = request.headers.get("Content-Type", "") 84 | 85 | if content_type.startswith("multipart/form-data"): 86 | data, files = _parse_multipart_form_data( 87 | content, 88 | content_type=content_type, 89 | encoding=request.headers.encoding, 90 | ) 91 | else: 92 | data = _parse_urlencoded_data( 93 | content, 94 | encoding=request.headers.encoding, 95 | ) 96 | files = MultiItems() 97 | 98 | return data, files 99 | 100 | 101 | Self = TypeVar("Self", bound="SetCookie") 102 | 103 | 104 | class SetCookie( 105 | NamedTuple( 106 | "SetCookie", 107 | [ 108 | ("header_name", Literal["Set-Cookie"]), 109 | ("header_value", str), 110 | ], 111 | ) 112 | ): 113 | def __new__( 114 | cls: Type[Self], 115 | name: str, 116 | value: str, 117 | *, 118 | path: Optional[str] = None, 119 | domain: Optional[str] = None, 120 | expires: Optional[Union[str, datetime]] = None, 121 | max_age: Optional[int] = None, 122 | http_only: bool = False, 123 | same_site: Optional[Literal["Strict", "Lax", "None"]] = None, 124 | secure: bool = False, 125 | partitioned: bool = False, 126 | ) -> Self: 127 | """ 128 | https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#syntax 129 | """ 130 | attrs: Dict[str, Union[str, bool]] = {name: value} 131 | if path is not None: 132 | attrs["Path"] = path 133 | if domain is not None: 134 | attrs["Domain"] = domain 135 | if expires is not None: 136 | if isinstance(expires, datetime): # pragma: no branch 137 | expires = expires.strftime("%a, %d %b %Y %H:%M:%S GMT") 138 | attrs["Expires"] = expires 139 | if max_age is not None: 140 | attrs["Max-Age"] = str(max_age) 141 | if http_only: 142 | attrs["HttpOnly"] = True 143 | if same_site is not None: 144 | attrs["SameSite"] = same_site 145 | if same_site == "None": # pragma: no branch 146 | secure = True 147 | if secure: 148 | attrs["Secure"] = True 149 | if partitioned: 150 | attrs["Partitioned"] = True 151 | 152 | string = "; ".join( 153 | _name if _value is True else f"{_name}={_value}" 154 | for _name, _value in attrs.items() 155 | ) 156 | self = super().__new__(cls, "Set-Cookie", string) 157 | return self 158 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 88 6 | ignore = B024,C408,E203,W503 7 | exclude = .git 8 | show-source = true 9 | 10 | [isort] 11 | line_length = 88 12 | known_first_party = respx 13 | default_section = THIRDPARTY 14 | multi_line_output = 3 15 | combine_as_imports = true 16 | include_trailing_comma = true 17 | force_grid_wrap = 0 18 | 19 | [tool:pytest] 20 | addopts = 21 | -p no:respx 22 | --cov=respx 23 | --cov=tests 24 | --cov-report=term-missing 25 | --cov-report=xml 26 | --cov-fail-under 100 27 | -rxXs 28 | asyncio_mode = auto 29 | asyncio_default_fixture_loop_scope = session 30 | 31 | [coverage:run] 32 | source = respx,tests 33 | branch = True 34 | 35 | [coverage:report] 36 | skip_covered = True 37 | show_missing = True 38 | 39 | [mypy] 40 | python_version = 3.8 41 | files = respx,tests 42 | pretty = True 43 | 44 | no_implicit_reexport = True 45 | no_implicit_optional = True 46 | strict_equality = True 47 | strict_optional = True 48 | check_untyped_defs = True 49 | disallow_incomplete_defs = True 50 | ignore_missing_imports = False 51 | 52 | warn_unused_configs = True 53 | warn_redundant_casts = True 54 | warn_unused_ignores = True 55 | warn_unreachable = True 56 | 57 | show_error_codes = True 58 | 59 | [mypy-pytest.*] 60 | ignore_missing_imports = True 61 | 62 | [mypy-trio.*] 63 | ignore_missing_imports = True 64 | 65 | [mypy-flask.*] 66 | ignore_missing_imports = True 67 | 68 | [mypy-starlette.*] 69 | ignore_missing_imports = True 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from pathlib import Path 3 | 4 | from setuptools import setup 5 | 6 | exec(Path("respx", "__version__.py").read_text()) # Load __version__ into locals 7 | 8 | setup( 9 | name="respx", 10 | version=locals()["__version__"], 11 | license="BSD-3-Clause", 12 | author="Jonas Lundberg", 13 | author_email="jonas@5monkeys.se", 14 | url="https://lundberg.github.io/respx/", 15 | keywords=["httpx", "httpcore", "mock", "responses", "requests", "async", "http"], 16 | description="A utility for mocking out the Python HTTPX and HTTP Core libraries.", 17 | long_description=Path("README.md").read_text("utf-8"), 18 | long_description_content_type="text/markdown", 19 | classifiers=[ 20 | "Development Status :: 5 - Production/Stable", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: BSD License", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | ], 33 | project_urls={ 34 | "GitHub": "https://github.com/lundberg/respx", 35 | "Changelog": "https://github.com/lundberg/respx/blob/master/CHANGELOG.md", 36 | "Issues": "https://github.com/lundberg/respx/issues", 37 | }, 38 | packages=["respx"], 39 | package_data={"respx": ["py.typed"]}, 40 | entry_points={"pytest11": ["respx = respx.plugin"]}, 41 | include_package_data=True, 42 | zip_safe=False, 43 | python_requires=">=3.8", 44 | install_requires=["httpx>=0.25.0"], 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lundberg/respx/40b42ceac07477ca3ab8abbb9bdaf8ada76775e9/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | 4 | import respx 5 | from respx.fixtures import * # noqa: F401, F403 6 | 7 | pytest_plugins = ["pytester"] 8 | 9 | 10 | @pytest.fixture 11 | async def client(): 12 | async with httpx.AsyncClient() as client: 13 | yield client 14 | 15 | 16 | @pytest.fixture 17 | async def my_mock(): 18 | async with respx.mock( 19 | base_url="https://httpx.mock", using="httpcore" 20 | ) as respx_mock: 21 | respx_mock.get("/", name="index").respond(404) 22 | yield respx_mock 23 | 24 | 25 | @pytest.fixture(scope="session") 26 | async def mocked_foo(session_event_loop): 27 | async with respx.mock( 28 | base_url="https://foo.api/api/", using="httpcore" 29 | ) as respx_mock: 30 | respx_mock.get("/", name="index").respond(202) 31 | respx_mock.get("/bar/", name="bar") 32 | yield respx_mock 33 | 34 | 35 | @pytest.fixture(scope="session") 36 | async def mocked_ham(session_event_loop): 37 | async with respx.mock(base_url="https://ham.api", using="httpcore") as respx_mock: 38 | respx_mock.get("/", name="index").respond(200) 39 | yield respx_mock 40 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json as jsonlib 3 | import re 4 | import socket 5 | from unittest import mock 6 | 7 | import httpcore 8 | import httpx 9 | import pytest 10 | 11 | import respx 12 | from respx.models import Route 13 | from respx.patterns import M 14 | from respx.router import MockRouter 15 | 16 | 17 | async def test_http_methods(client): 18 | async with respx.mock: 19 | url = "https://foo.bar" 20 | route = respx.get(url, path="/") % 404 21 | respx.post(url, path="/").respond(200) 22 | respx.post(url, path="/").respond(201) 23 | respx.put(url, path="/").respond(202) 24 | respx.patch(url, path="/").respond(500) 25 | respx.delete(url, path="/").respond(204) 26 | respx.head(url, path="/").respond(405) 27 | respx.options(url, path="/").respond(status_code=501) 28 | respx.request("GET", url, path="/baz/").respond(status_code=204) 29 | url += "/" 30 | 31 | response = httpx.get(url) 32 | assert response.status_code == 404 33 | response = await client.get(url) 34 | assert response.status_code == 404 35 | 36 | response = httpx.get(url + "baz/") 37 | assert response.status_code == 204 38 | response = await client.get(url + "baz/") 39 | assert response.status_code == 204 40 | 41 | response = httpx.post(url) 42 | assert response.status_code == 201 43 | response = await client.post(url) 44 | assert response.status_code == 201 45 | 46 | response = httpx.put(url) 47 | assert response.status_code == 202 48 | response = await client.put(url) 49 | assert response.status_code == 202 50 | 51 | response = httpx.patch(url) 52 | assert response.status_code == 500 53 | response = await client.patch(url) 54 | assert response.status_code == 500 55 | 56 | response = httpx.delete(url) 57 | assert response.status_code == 204 58 | response = await client.delete(url) 59 | assert response.status_code == 204 60 | 61 | response = httpx.head(url) 62 | assert response.status_code == 405 63 | response = await client.head(url) 64 | assert response.status_code == 405 65 | 66 | response = httpx.options(url) 67 | assert response.status_code == 501 68 | response = await client.options(url) 69 | assert response.status_code == 501 70 | 71 | assert route.called is True 72 | assert respx.calls.call_count == 8 * 2 73 | 74 | 75 | @pytest.mark.parametrize( 76 | ("url", "pattern"), 77 | [ 78 | ("https://foo.bar", "https://foo.bar"), 79 | ("https://foo.bar/baz/", None), 80 | ("https://foo.bar/baz/", ""), 81 | ("https://foo.bar/baz/", "https://foo.bar/baz/"), 82 | ("https://foo.bar/baz/", re.compile(r"^https://foo.bar/\w+/$")), 83 | ("https://foo.bar/baz/", (b"https", b"foo.bar", None, b"/baz/")), 84 | ("https://foo.bar:443/baz/", (b"https", b"foo.bar", 443, b"/baz/")), 85 | ("https://foo.bar/%08", "https://foo.bar/%08"), 86 | ], 87 | ) 88 | async def test_url_match(client, url, pattern): 89 | async with MockRouter(assert_all_mocked=False) as respx_mock: 90 | request = respx_mock.get(pattern) % dict(content="baz") 91 | response = await client.get(url) 92 | assert request.called is True 93 | assert response.status_code == 200 94 | assert response.text == "baz" 95 | 96 | 97 | async def test_invalid_url_pattern(): 98 | async with MockRouter() as respx_mock: 99 | with pytest.raises(TypeError): 100 | respx_mock.get(["invalid"]) # type: ignore[arg-type] 101 | 102 | 103 | async def test_repeated_pattern(client): 104 | async with MockRouter() as respx_mock: 105 | url = "https://foo/bar/baz/" 106 | route = respx_mock.post(url) 107 | route.side_effect = [ 108 | httpx.Response(201), 109 | httpx.Response(409), 110 | ] 111 | 112 | response1 = await client.post(url, json={}) 113 | response2 = await client.post(url, json={}) 114 | with pytest.raises(RuntimeError): 115 | await client.post(url, json={}) 116 | 117 | assert response1.status_code == 201 118 | assert response2.status_code == 409 119 | assert respx_mock.calls.call_count == 2 120 | 121 | assert route.called is True 122 | assert route.call_count == 2 123 | statuses = [call.response.status_code for call in route.calls] 124 | assert statuses == [201, 409] 125 | 126 | 127 | async def test_status_code(client): 128 | async with MockRouter() as respx_mock: 129 | url = "https://foo.bar/" 130 | request = respx_mock.get(url) % 404 131 | response = await client.get(url) 132 | 133 | assert request.called is True 134 | assert response.status_code == 404 135 | 136 | 137 | @pytest.mark.parametrize( 138 | ("headers", "content_type", "expected"), 139 | [ 140 | ({"X-Foo": "bar"}, None, {"X-Foo": "bar"}), 141 | ( 142 | {"Content-Type": "foo/bar", "X-Foo": "bar"}, 143 | None, 144 | {"Content-Type": "foo/bar", "X-Foo": "bar"}, 145 | ), 146 | ( 147 | {"Content-Type": "foo/bar", "X-Foo": "bar"}, 148 | "ham/spam", 149 | {"Content-Type": "ham/spam", "X-Foo": "bar"}, 150 | ), 151 | ], 152 | ) 153 | async def test_headers(client, headers, content_type, expected): 154 | async with MockRouter() as respx_mock: 155 | url = "https://foo.bar/" 156 | request = respx_mock.get(url).respond( 157 | headers=headers, content_type=content_type 158 | ) 159 | response = await client.get(url) 160 | assert request.called is True 161 | assert response.headers == httpx.Headers(expected) 162 | 163 | 164 | @pytest.mark.parametrize( 165 | ("content", "expected"), 166 | [ 167 | (b"eldr\xc3\xa4v", "eldräv"), 168 | ("äpple", "äpple"), 169 | ("Gehäusegröße", "Gehäusegröße"), 170 | ], 171 | ) 172 | async def test_text_encoding(client, content, expected): 173 | async with MockRouter() as respx_mock: 174 | url = "https://foo.bar/" 175 | request = respx_mock.post(url) % dict(content=content) 176 | response = await client.post(url) 177 | assert request.called is True 178 | assert response.text == expected 179 | 180 | 181 | @pytest.mark.parametrize( 182 | ("key", "value", "expected_content_type"), 183 | [ 184 | ("content", b"foobar", None), 185 | ("content", "foobar", None), 186 | ("json", ["foo", "bar"], "application/json"), 187 | ("json", {"foo": "bar"}, "application/json"), 188 | ("text", "foobar", "text/plain; charset=utf-8"), 189 | ("html", "foobar", "text/html; charset=utf-8"), 190 | ], 191 | ) 192 | async def test_content_variants(client, key, value, expected_content_type): 193 | async with MockRouter() as respx_mock: 194 | url = "https://foo.bar/" 195 | request = respx_mock.get(url) % {key: value} 196 | 197 | async_response = await client.get(url) 198 | assert request.called is True 199 | assert async_response.headers.get("Content-Type") == expected_content_type 200 | assert async_response.content is not None 201 | 202 | respx_mock.reset() 203 | sync_response = httpx.get(url) 204 | assert request.called is True 205 | assert sync_response.headers.get("Content-Type") == expected_content_type 206 | assert sync_response.content is not None 207 | 208 | 209 | @pytest.mark.parametrize( 210 | ("content", "headers", "expected_headers"), 211 | [ 212 | ( 213 | {"foo": "bar"}, 214 | {"X-Foo": "bar"}, 215 | { 216 | "Content-Type": "application/json", 217 | "Content-Length": "13", 218 | "X-Foo": "bar", 219 | }, 220 | ), 221 | ( 222 | ["foo", "bar"], 223 | {"Content-Type": "application/json; charset=utf-8", "X-Foo": "bar"}, 224 | { 225 | "Content-Type": "application/json; charset=utf-8", 226 | "Content-Length": "13", 227 | "X-Foo": "bar", 228 | }, 229 | ), 230 | ], 231 | ) 232 | async def test_json_content(client, content, headers, expected_headers): 233 | async with MockRouter() as respx_mock: 234 | url = "https://foo.bar/" 235 | request = respx_mock.get(url) % dict(json=content, headers=headers) 236 | 237 | async_response = await client.get(url) 238 | assert request.called is True 239 | assert async_response.headers == httpx.Headers(expected_headers) 240 | assert async_response.json() == content 241 | 242 | respx_mock.reset() 243 | sync_response = httpx.get(url) 244 | assert request.called is True 245 | assert sync_response.headers == httpx.Headers(expected_headers) 246 | assert sync_response.json() == content 247 | 248 | 249 | def test_json_post_body(): 250 | post_url = "https://example.org/" 251 | get_url = "https://something.else/" 252 | 253 | with respx.mock: 254 | post_route = respx.post(post_url, json={"foo": "bar"}) % 201 255 | get_route = respx.get(get_url) % 204 256 | 257 | post_response = httpx.post(post_url, json={"foo": "bar"}) 258 | assert post_response.status_code == 201 259 | assert post_route.called 260 | 261 | get_response = httpx.get(get_url) 262 | assert get_response.status_code == 204 263 | assert get_route.called 264 | 265 | 266 | def test_data_post_body(): 267 | with respx.mock: 268 | url = "https://foo.bar/" 269 | route = respx.post(url, data={"foo": "bar"}) % 201 270 | response = httpx.post(url, data={"foo": "bar"}, files={"file": b"..."}) 271 | assert response.status_code == 201 272 | assert route.called 273 | 274 | 275 | def test_files_post_body(): 276 | with respx.mock: 277 | url = "https://foo.bar/" 278 | file = ("file", ("filename.txt", b"...", "text/plain", {"X-Foo": "bar"})) 279 | route = respx.post(url, files={"file": mock.ANY}) % 201 280 | response = httpx.post(url, files=[file]) 281 | assert response.status_code == 201 282 | assert route.called 283 | 284 | 285 | async def test_raising_content(client): 286 | async with MockRouter() as respx_mock: 287 | url = "https://foo.bar/" 288 | request = respx_mock.get(url) 289 | request.side_effect = httpx.ConnectTimeout("X-P", request=None) 290 | with pytest.raises(httpx.ConnectTimeout): 291 | await client.get(url) 292 | 293 | assert request.called is True 294 | _request, _response = request.calls[-1] 295 | assert _request is not None 296 | assert _response is None 297 | 298 | # Test httpx exception class get instantiated 299 | route = respx_mock.get(url).mock(side_effect=httpx.ConnectError) 300 | with pytest.raises(httpx.ConnectError): 301 | await client.get(url) 302 | 303 | assert route.call_count == 2 304 | assert route.calls.last.request is not None 305 | assert route.calls.last.has_response is False 306 | with pytest.raises(ValueError, match="has no response"): 307 | assert route.calls.last.response 308 | 309 | 310 | async def test_callable_content(client): 311 | async with MockRouter() as respx_mock: 312 | url_pattern = re.compile(r"https://foo.bar/(?P\w+)/") 313 | 314 | def content_callback(request, slug): 315 | content = jsonlib.loads(request.content) 316 | return respx.MockResponse(content=f"hello {slug}{content['x']}") 317 | 318 | request = respx_mock.post(url_pattern) 319 | request.side_effect = content_callback 320 | 321 | async_response = await client.post("https://foo.bar/world/", json={"x": "."}) 322 | assert request.called is True 323 | assert async_response.status_code == 200 324 | assert async_response.text == "hello world." 325 | assert request.calls[-1][0].content == b'{"x":"."}' 326 | 327 | respx_mock.reset() 328 | sync_response = httpx.post("https://foo.bar/jonas/", json={"x": "!"}) 329 | assert request.called is True 330 | assert sync_response.status_code == 200 331 | assert sync_response.text == "hello jonas!" 332 | assert request.calls[-1][0].content == b'{"x":"!"}' 333 | 334 | 335 | async def test_request_callback(client): 336 | def callback(request, name): 337 | if request.url.host == "foo.bar" and request.content == b'{"foo":"bar"}': 338 | return respx.MockResponse( 339 | 202, 340 | headers={"X-Foo": "bar"}, 341 | text=f"hello {name}", 342 | http_version="HTTP/2", 343 | ) 344 | return httpx.Response(404) 345 | 346 | async with MockRouter(assert_all_called=False) as respx_mock: 347 | request = respx_mock.post(host="foo.bar", path__regex=r"/(?P\w+)/") 348 | request.side_effect = callback 349 | 350 | response = await client.post("https://foo.bar/lundberg/") 351 | assert response.status_code == 404 352 | 353 | response = await client.post("https://foo.bar/lundberg/", json={"foo": "bar"}) 354 | assert request.called is True 355 | assert not request.is_pass_through 356 | assert response.status_code == 202 357 | assert response.http_version == "HTTP/2" 358 | assert response.headers == httpx.Headers( 359 | { 360 | "Content-Type": "text/plain; charset=utf-8", 361 | "Content-Length": "14", 362 | "X-Foo": "bar", 363 | } 364 | ) 365 | assert response.text == "hello lundberg" 366 | 367 | respx_mock.get("https://ham.spam/").mock( 368 | side_effect=lambda req: "invalid" # type: ignore[arg-type] 369 | ) 370 | 371 | def _callback(request): 372 | raise httpcore.NetworkError() 373 | 374 | respx_mock.get("https://egg.plant").mock(side_effect=_callback) 375 | 376 | with pytest.raises(TypeError): 377 | await client.get("https://ham.spam/") 378 | 379 | with pytest.raises(httpx.NetworkError): 380 | await client.get("https://egg.plant/") 381 | 382 | 383 | @pytest.mark.parametrize( 384 | ("using", "route", "expected"), 385 | [ 386 | ("httpcore", Route(url="https://example.org/").pass_through(), True), 387 | ("httpx", Route(url="https://example.org/").pass_through(), True), 388 | ("httpcore", Route().mock(side_effect=lambda request: request), False), 389 | ("httpcore", Route().pass_through(), True), 390 | ], 391 | ) 392 | async def test_pass_through(client, using, route, expected): 393 | async with MockRouter(using=using) as respx_mock: 394 | request = respx_mock.add(route) 395 | 396 | with mock.patch( 397 | "anyio.connect_tcp", 398 | side_effect=ConnectionRefusedError("test request blocked"), 399 | ) as open_connection: 400 | with pytest.raises(httpx.NetworkError): 401 | await client.get("https://example.org/") 402 | 403 | assert open_connection.called is True 404 | assert request.called is True 405 | assert request.is_pass_through is expected 406 | 407 | with MockRouter(using=using) as respx_mock: 408 | request = respx_mock.add(route) 409 | 410 | with mock.patch( 411 | "socket.create_connection", side_effect=socket.error("test request blocked") 412 | ) as connect: 413 | with pytest.raises(httpx.NetworkError): 414 | httpx.get("https://example.org/") 415 | 416 | assert connect.called is True 417 | assert request.called is True 418 | assert request.is_pass_through is expected 419 | 420 | 421 | @respx.mock 422 | async def test_parallel_requests(client): 423 | def content(request, page): 424 | return httpx.Response(200, text=page) 425 | 426 | url_pattern = re.compile(r"https://foo/(?P\w+)/$") 427 | respx.get(url_pattern).mock(side_effect=content) 428 | 429 | responses = await asyncio.gather( 430 | client.get("https://foo/one/"), client.get("https://foo/two/") 431 | ) 432 | response_one, response_two = responses 433 | 434 | assert response_one.text == "one" 435 | assert response_two.text == "two" 436 | assert respx.calls.call_count == 2 437 | 438 | 439 | @pytest.mark.parametrize( 440 | ("method_str", "client_method_attr"), 441 | [ 442 | ("DELETE", "delete"), 443 | ("delete", "delete"), 444 | ("GET", "get"), 445 | ("get", "get"), 446 | ("HEAD", "head"), 447 | ("head", "head"), 448 | ("OPTIONS", "options"), 449 | ("options", "options"), 450 | ("PATCH", "patch"), 451 | ("patch", "patch"), 452 | ("POST", "post"), 453 | ("post", "post"), 454 | ("PUT", "put"), 455 | ("put", "put"), 456 | ], 457 | ) 458 | async def test_method_case(client, method_str, client_method_attr): 459 | url = "https://example.org/" 460 | content = {"spam": "lots", "ham": "no, only spam"} 461 | async with MockRouter() as respx_mock: 462 | request = respx_mock.route(method=method_str, url=url) % dict(json=content) 463 | response = await getattr(client, client_method_attr)(url) 464 | assert request.called is True 465 | assert response.json() == content 466 | 467 | 468 | def test_pop(): 469 | with respx.mock: 470 | request = respx.get("https://foo.bar/", name="foobar") 471 | popped = respx.pop("foobar") 472 | assert popped is request 473 | 474 | with pytest.raises(KeyError): 475 | respx.pop("foobar") 476 | 477 | assert respx.pop("foobar", "custom default") == "custom default" 478 | 479 | 480 | @respx.mock 481 | @pytest.mark.parametrize( 482 | ("url", "params", "call_url", "call_params"), 483 | [ 484 | ("https://foo/", "foo=bar", "https://foo/", "foo=bar"), 485 | ("https://foo/", b"foo=bar", "https://foo/", b"foo=bar"), 486 | ("https://foo/", [("foo", "bar")], "https://foo/", [("foo", "bar")]), 487 | ("https://foo/", {"foo": "bar"}, "https://foo/", {"foo": "bar"}), 488 | ("https://foo/", (("foo", "bar"),), "https://foo/", (("foo", "bar"),)), 489 | ("https://foo?foo=bar", "baz=qux", "https://foo?foo=bar", "baz=qux"), 490 | ("https://foo?foo=bar", "baz=qux", "https://foo?foo=bar&baz=qux", None), 491 | (re.compile(r"https://foo/(\w+)/"), "foo=bar", "https://foo/bar/", "foo=bar"), 492 | (httpx.URL("https://foo/"), "foo=bar", "https://foo/", "foo=bar"), 493 | ( 494 | httpx.URL("https://foo?foo=bar"), 495 | "baz=qux", 496 | "https://foo?foo=bar&baz=qux", 497 | None, 498 | ), 499 | ], 500 | ) 501 | async def test_params_match(client, url, params, call_url, call_params): 502 | respx.get(url, params=params) % dict(content="spam spam") 503 | response = await client.get(call_url, params=call_params) 504 | assert response.text == "spam spam" 505 | 506 | 507 | @pytest.mark.parametrize( 508 | ("base", "url"), 509 | [ 510 | (None, "https://foo.bar/baz/"), 511 | ("", "https://foo.bar/baz/"), 512 | ("https://foo.bar", "baz/"), 513 | ("https://foo.bar/", "baz/"), 514 | ("https://foo.bar/", "/baz/"), 515 | ("https://foo.bar/baz/", None), 516 | ("https://foo.bar/", re.compile(r"/(\w+)/")), 517 | ], 518 | ) 519 | async def test_build_url_base(client, base, url): 520 | with respx.mock(base_url=base) as respx_mock: 521 | respx_mock.get(url) % dict(content="spam spam") 522 | response = await client.get("https://foo.bar/baz/") 523 | assert response.text == "spam spam" 524 | 525 | 526 | def test_add(): 527 | with respx.mock: 528 | route = Route(method="GET", url="https://foo.bar/") 529 | respx.add(route, name="foobar") 530 | 531 | response = httpx.get("https://foo.bar/") 532 | assert response.status_code == 200 533 | assert respx.routes["foobar"].called 534 | 535 | with pytest.raises(TypeError): 536 | respx.add(route, status_code=418) # type: ignore[call-arg] 537 | 538 | with pytest.raises(ValueError, match="Invalid route"): 539 | respx.add("GET") # type: ignore[arg-type] 540 | 541 | with pytest.raises(NotImplementedError): 542 | route.name = "spam" 543 | 544 | with pytest.raises(NotImplementedError): 545 | route.pattern &= M(params={"foo": "bar"}) 546 | 547 | 548 | def test_respond(): 549 | with respx.mock: 550 | route = respx.get("https://foo.bar/").respond( 551 | content="lundberg", 552 | content_type="text/xml", 553 | http_version="HTTP/2", 554 | ) 555 | response = httpx.get("https://foo.bar/") 556 | assert response.status_code == 200 557 | assert response.headers.get("Content-Type") == "text/xml" 558 | assert response.http_version == "HTTP/2" 559 | 560 | with pytest.raises(TypeError, match="content can only be"): 561 | route.respond(content={}) 562 | 563 | with pytest.raises(TypeError, match="content can only be"): 564 | route.respond(content=Exception()) # type: ignore[arg-type] 565 | 566 | 567 | def test_can_respond_with_cookies(): 568 | with respx.mock: 569 | route = respx.get("https://foo.bar/").respond( 570 | json={}, headers={"X-Foo": "bar"}, cookies={"foo": "bar", "ham": "spam"} 571 | ) 572 | response = httpx.get("https://foo.bar/") 573 | assert len(response.headers) == 5 574 | assert response.headers["X-Foo"] == "bar", "mocked header is missing" 575 | assert len(response.cookies) == 2 576 | assert response.cookies["foo"] == "bar" 577 | assert response.cookies["ham"] == "spam" 578 | 579 | route.respond(cookies=[("egg", "yolk")]) 580 | response = httpx.get("https://foo.bar/") 581 | assert len(response.cookies) == 1 582 | assert response.cookies["egg"] == "yolk" 583 | 584 | route.respond( 585 | cookies=[respx.SetCookie("foo", "bar", path="/", same_site="Lax")] 586 | ) 587 | response = httpx.get("https://foo.bar/") 588 | assert len(response.cookies) == 1 589 | assert response.cookies["foo"] == "bar" 590 | 591 | 592 | def test_can_mock_response_with_set_cookie_headers(): 593 | request = httpx.Request("GET", "https://example.com/") 594 | response = httpx.Response( 595 | 200, 596 | headers=[ 597 | respx.SetCookie("foo", value="bar"), 598 | respx.SetCookie("ham", value="spam"), 599 | ], 600 | request=request, 601 | ) 602 | assert len(response.cookies) == 2 603 | assert response.cookies["foo"] == "bar" 604 | assert response.cookies["ham"] == "spam" 605 | 606 | 607 | @pytest.mark.parametrize( 608 | "kwargs", 609 | [ 610 | {"content": b"foobar"}, 611 | {"content": "foobar"}, 612 | {"json": {"foo": "bar"}}, 613 | {"json": [{"foo": "bar", "ham": "spam"}, {"zoo": "apa", "egg": "yolk"}]}, 614 | {"data": {"animal": "Räv", "name": "Röda Räven"}}, 615 | ], 616 | ) 617 | async def test_async_post_content(kwargs): 618 | async with respx.mock: 619 | respx.post("https://foo.bar/", **kwargs) % 201 620 | async with httpx.AsyncClient() as client: 621 | response = await client.post("https://foo.bar/", **kwargs) 622 | assert response.status_code == 201 623 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | 3 | 4 | def test_respx_mock_fixture(testdir): 5 | testdir.makepyfile( 6 | """ 7 | import httpx 8 | import pytest 9 | 10 | @pytest.fixture 11 | def some_fixture(): 12 | yield "foobar" 13 | 14 | def test_plain_fixture(respx_mock): 15 | route = respx_mock.get("https://foo.bar/") % 204 16 | response = httpx.get("https://foo.bar/") 17 | assert response.status_code == 204 18 | 19 | 20 | @pytest.mark.respx(base_url="https://foo.bar", assert_all_mocked=False) 21 | def test_marked_fixture(respx_mock): 22 | route = respx_mock.get("/") % 204 23 | response = httpx.get("https://foo.bar/") 24 | assert response.status_code == 204 25 | response = httpx.get("https://example.org/") 26 | assert response.status_code == 200 27 | 28 | 29 | def test_with_extra_fixture(respx_mock, some_fixture): 30 | import respx 31 | assert isinstance(respx_mock, respx.Router) 32 | assert some_fixture == "foobar" 33 | 34 | 35 | @pytest.mark.respx(assert_all_mocked=False) 36 | def test_marked_with_extra_fixture(respx_mock, some_fixture): 37 | import respx 38 | assert isinstance(respx_mock, respx.Router) 39 | assert some_fixture == "foobar" 40 | """ 41 | ) 42 | testdir.makeini( 43 | dedent( 44 | """ 45 | [pytest] 46 | asyncio-mode = auto 47 | asyncio_default_fixture_loop_scope = session 48 | """ 49 | ) 50 | ) 51 | result = testdir.runpytest("-p", "respx") 52 | result.assert_outcomes(passed=4) 53 | -------------------------------------------------------------------------------- /tests/test_remote.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | import respx 6 | 7 | pytestmark = pytest.mark.skipif( 8 | os.environ.get("PASS_THROUGH") is None, reason="Remote pass-through disabled" 9 | ) 10 | 11 | 12 | @pytest.mark.parametrize( 13 | ("using", "client_lib", "call_count"), 14 | [ 15 | ("httpcore", "httpx", 2), # TODO: AsyncConnectionPool + AsyncHTTPConnection 16 | ("httpx", "httpx", 1), 17 | ], 18 | ) 19 | def test_remote_pass_through(using, client_lib, call_count): # pragma: nocover 20 | with respx.mock(using=using) as respx_mock: 21 | # Mock pass-through calls 22 | url = "https://httpbin.org/post" 23 | route = respx_mock.post(url, json__foo="bar").pass_through() 24 | 25 | # Make external pass-through call 26 | client = __import__(client_lib) 27 | response = client.post(url, json={"foo": "bar"}) 28 | 29 | # Assert response is correct library model 30 | assert isinstance(response, client.Response) 31 | 32 | assert response.status_code == 200 33 | assert response.content is not None 34 | assert len(response.content) > 0 35 | assert "Content-Length" in response.headers 36 | assert int(response.headers["Content-Length"]) > 0 37 | assert response.json()["json"] == {"foo": "bar"} 38 | 39 | assert respx_mock.calls.last.request.url == url 40 | assert respx_mock.calls.last.has_response is False 41 | 42 | assert route.call_count == call_count 43 | assert respx_mock.calls.call_count == call_count 44 | -------------------------------------------------------------------------------- /tests/test_router.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import httpcore 4 | import httpx 5 | import pytest 6 | 7 | from respx import Route, Router 8 | from respx.models import AllMockedAssertionError, PassThrough, RouteList 9 | from respx.patterns import Host, M, Method 10 | 11 | 12 | async def test_empty_router(): 13 | router = Router() 14 | 15 | request = httpx.Request("GET", "https://example.org/") 16 | with pytest.raises(AllMockedAssertionError): 17 | router.resolve(request) 18 | 19 | with pytest.raises(AllMockedAssertionError): 20 | await router.aresolve(request) 21 | 22 | 23 | async def test_empty_router__auto_mocked(): 24 | router = Router(assert_all_mocked=False) 25 | 26 | request = httpx.Request("GET", "https://example.org/") 27 | resolved = router.resolve(request) 28 | 29 | assert resolved.route is None 30 | assert isinstance(resolved.response, httpx.Response) 31 | assert resolved.response.status_code == 200 32 | 33 | resolved = await router.aresolve(request) 34 | 35 | assert resolved.route is None 36 | assert isinstance(resolved.response, httpx.Response) 37 | assert resolved.response.status_code == 200 38 | 39 | 40 | @pytest.mark.parametrize( 41 | ("args", "kwargs", "expected"), 42 | [ 43 | ((Method("GET"), Host("foo.bar")), dict(), True), 44 | (tuple(), dict(method="GET", host="foo.bar"), True), 45 | ((Method("GET"),), dict(port=443, url__regex=r"/baz/$"), True), 46 | ((Method("POST"),), dict(host="foo.bar"), False), 47 | ((~Method("GET"),), dict(), False), 48 | ((~M(url__regex=r"/baz/$"),), dict(), False), 49 | (tuple(), dict(headers={"host": "foo.bar"}), True), 50 | (tuple(), dict(headers={"Content-Type": "text/plain"}), False), 51 | (tuple(), dict(headers={"cookie": "foo=bar"}), False), 52 | (tuple(), dict(cookies={"ham": "spam"}), True), 53 | ], 54 | ) 55 | def test_resolve(args, kwargs, expected): 56 | router = Router(assert_all_mocked=False) 57 | route = router.route(*args, **kwargs).respond(status_code=201) 58 | 59 | request = httpx.Request( 60 | "GET", "https://foo.bar/baz/", cookies={"foo": "bar", "ham": "spam"} 61 | ) 62 | resolved = router.resolve(request) 63 | 64 | assert bool(resolved.route is route) is expected 65 | assert isinstance(resolved.response, httpx.Response) 66 | if expected: 67 | assert bool(resolved.response.status_code == 201) is expected 68 | else: 69 | assert resolved.response.status_code == 200 # auto mocked 70 | 71 | 72 | def test_pass_through(): 73 | router = Router(assert_all_mocked=False) 74 | route = router.get("https://foo.bar/", path="/baz/").pass_through() 75 | 76 | request = httpx.Request("GET", "https://foo.bar/baz/") 77 | with pytest.raises(PassThrough) as exc_info: 78 | router.resolve(request) 79 | 80 | assert exc_info.value.origin is route 81 | assert exc_info.value.origin.is_pass_through 82 | 83 | route.pass_through(False) 84 | resolved = router.resolve(request) 85 | 86 | assert resolved.route is not None 87 | assert resolved.route is route 88 | assert not resolved.route.is_pass_through 89 | assert resolved.response is not None 90 | 91 | 92 | @pytest.mark.parametrize( 93 | ("url", "lookups", "expected"), 94 | [ 95 | ("https://foo.bar/api/baz/", {"url": "/baz/"}, True), 96 | ("https://foo.bar/api/baz/", {"path__regex": r"^/(?P\w+)/$"}, True), 97 | ("http://foo.bar/api/baz/", {"url": "/baz/"}, False), 98 | ("https://ham.spam/api/baz/", {"url": "/baz/"}, False), 99 | ("https://foo.bar/baz/", {"url": "/baz/"}, False), 100 | ("https://foo.bar/api/hej:svejs", {"url": "/hej:svejs"}, True), 101 | ], 102 | ) 103 | def test_base_url(url, lookups, expected): 104 | router = Router(base_url="https://foo.bar/api/", assert_all_mocked=False) 105 | route = router.get(**lookups).respond(201) 106 | 107 | request = httpx.Request("GET", url) 108 | resolved = router.resolve(request) 109 | 110 | assert bool(resolved.route is route) is expected 111 | assert isinstance(resolved.response, httpx.Response) 112 | if expected: 113 | assert bool(resolved.response.status_code == 201) is expected 114 | else: 115 | assert resolved.response.status_code == 200 # auto mocked 116 | 117 | 118 | @pytest.mark.parametrize( 119 | ("lookups", "url", "expected"), 120 | [ 121 | ({"url": "//foo.bar/baz/"}, "https://foo.bar/baz/", True), 122 | ({"url": "all"}, "https://foo.bar/baz/", True), 123 | ({"url": "all://"}, "https://foo.bar/baz/", True), 124 | ({"url": "https://*foo.bar"}, "https://foo.bar/baz/", True), 125 | ({"url": "https://*foo.bar"}, "https://baz.foo.bar/", True), 126 | ({"url": "https://*.foo.bar"}, "https://foo.bar/baz/", False), 127 | ({"url": "https://*.foo.bar"}, "https://baz.foo.bar/", True), 128 | ({"url__eq": "https://foo.bar/baz/"}, "https://foo.bar/baz/", True), 129 | ({"url__eq": "https://foo.bar/baz/"}, "http://foo.bar/baz/", False), 130 | ({"url__eq": "https://foo.bar"}, "https://foo.bar/", True), 131 | ({"url__eq": "https://foo.bar/"}, "https://foo.bar", True), 132 | ( 133 | {"url": "https://foo.bar/", "path__regex": r"/(?P\w+)/"}, 134 | "https://foo.bar/baz/", 135 | True, 136 | ), 137 | ], 138 | ) 139 | def test_url_pattern_lookup(lookups, url, expected): 140 | router = Router(assert_all_mocked=False) 141 | route = router.get(**lookups) % 418 142 | request = httpx.Request("GET", url) 143 | response = router.handler(request) 144 | assert bool(response.status_code == 418) is expected 145 | assert route.called is expected 146 | 147 | 148 | def test_mod_response(): 149 | router = Router() 150 | route1a = router.get("https://foo.bar/baz/") % 409 151 | route1b = router.get("https://foo.bar/baz/") % 404 152 | route2 = router.get("https://foo.bar") % dict(status_code=201) 153 | route3 = router.post("https://fox.zoo/") % httpx.Response(401, json={"error": "x"}) 154 | 155 | request = httpx.Request("GET", "https://foo.bar/baz/") 156 | resolved = router.resolve(request) 157 | assert isinstance(resolved.response, httpx.Response) 158 | assert resolved.response.status_code == 404 159 | assert resolved.route is route1b 160 | assert route1a is route1b 161 | 162 | request = httpx.Request("GET", "https://foo.bar/") 163 | resolved = router.resolve(request) 164 | assert isinstance(resolved.response, httpx.Response) 165 | assert resolved.response.status_code == 201 166 | assert resolved.route is route2 167 | 168 | request = httpx.Request("POST", "https://fox.zoo/") 169 | resolved = router.resolve(request) 170 | assert isinstance(resolved.response, httpx.Response) 171 | assert resolved.response.status_code == 401 172 | assert resolved.response.json() == {"error": "x"} 173 | assert resolved.route is route3 174 | 175 | with pytest.raises(TypeError, match="Route can only"): 176 | router.route() % [] # type: ignore[operator] 177 | 178 | 179 | async def test_async_side_effect(): 180 | router = Router() 181 | 182 | async def effect(request): 183 | return httpx.Response(204) 184 | 185 | router.get("https://foo.bar/").mock(side_effect=effect) 186 | 187 | request = httpx.Request("GET", "https://foo.bar/") 188 | response = await router.async_handler(request) 189 | assert response.status_code == 204 190 | 191 | 192 | def test_side_effect_no_match(): 193 | router = Router() 194 | 195 | def no_match(request): 196 | request.respx_was_here = True 197 | return None 198 | 199 | router.get(url__startswith="https://foo.bar/").mock(side_effect=no_match) 200 | router.get(url__eq="https://foo.bar/baz/").mock(return_value=httpx.Response(204)) 201 | 202 | request = httpx.Request("GET", "https://foo.bar/baz/") 203 | response = router.handler(request) 204 | assert response.status_code == 204 205 | assert response.request.respx_was_here is True # type: ignore[attr-defined] 206 | 207 | 208 | def test_side_effect_with_route_kwarg(): 209 | router = Router() 210 | 211 | def foobar(request, route, slug): 212 | response = httpx.Response(201, json={"id": route.call_count + 1, "slug": slug}) 213 | if route.call_count > 0: 214 | route.mock(return_value=httpx.Response(501)) 215 | return response 216 | 217 | router.post(path__regex=r"/(?P\w+)/").mock(side_effect=foobar) 218 | 219 | request = httpx.Request("POST", "https://foo.bar/baz/") 220 | response = router.handler(request) 221 | assert response.status_code == 201 222 | assert response.json() == {"id": 1, "slug": "baz"} 223 | 224 | response = router.handler(request) 225 | assert response.status_code == 201 226 | assert response.json() == {"id": 2, "slug": "baz"} 227 | 228 | response = router.handler(request) 229 | assert response.status_code == 501 230 | 231 | 232 | def test_side_effect_with_reserved_route_kwarg(): 233 | router = Router() 234 | 235 | def foobar(request, route): 236 | assert isinstance(route, Route) 237 | return httpx.Response(202) 238 | 239 | router.get(path__regex=r"/(?P\w+)/").mock(side_effect=foobar) 240 | 241 | with warnings.catch_warnings(record=True) as w: 242 | request = httpx.Request("GET", "https://foo.bar/baz/") 243 | response = router.handler(request) 244 | assert response.status_code == 202 245 | assert len(w) == 1 246 | 247 | 248 | def test_side_effect_list(): 249 | router = Router() 250 | route = router.get("https://foo.bar/").mock( 251 | return_value=httpx.Response(409), 252 | side_effect=[httpx.Response(404), httpcore.NetworkError, httpx.Response(201)], 253 | ) 254 | 255 | request = httpx.Request("GET", "https://foo.bar") 256 | response = router.handler(request) 257 | assert response.status_code == 404 258 | assert response.request == request 259 | 260 | request = httpx.Request("GET", "https://foo.bar") 261 | with pytest.raises(httpcore.NetworkError): 262 | router.handler(request) 263 | 264 | request = httpx.Request("GET", "https://foo.bar") 265 | response = router.handler(request) 266 | assert response.status_code == 201 267 | assert response.request == request 268 | 269 | request = httpx.Request("GET", "https://foo.bar") 270 | with pytest.raises(StopIteration): 271 | router.handler(request) 272 | 273 | route.side_effect = None 274 | request = httpx.Request("GET", "https://foo.bar") 275 | response = router.handler(request) 276 | assert response.status_code == 409 277 | assert response.request == request 278 | 279 | 280 | def test_side_effect_exception(): 281 | router = Router() 282 | router.get("https://foo.bar/").mock(side_effect=httpx.ConnectError) 283 | router.get("https://ham.spam/").mock(side_effect=httpcore.NetworkError) 284 | router.get("https://egg.plant/").mock(side_effect=httpcore.NetworkError()) 285 | 286 | request = httpx.Request("GET", "https://foo.bar") 287 | with pytest.raises(httpx.ConnectError) as e: 288 | router.handler(request) 289 | assert e.value.request == request 290 | 291 | request = httpx.Request("GET", "https://ham.spam") 292 | with pytest.raises(httpcore.NetworkError): 293 | router.handler(request) 294 | 295 | request = httpx.Request("GET", "https://egg.plant") 296 | with pytest.raises(httpcore.NetworkError): 297 | router.handler(request) 298 | 299 | 300 | def test_side_effect_decorator(): 301 | router = Router() 302 | 303 | @router.route(host="ham.spam", path__regex=r"/(?P\w+)/") 304 | def foobar(request, slug): 305 | return httpx.Response(200, json={"slug": slug}) 306 | 307 | @router.post("https://example.org/") 308 | def example(request): 309 | return httpx.Response(201, json={"message": "OK"}) 310 | 311 | request = httpx.Request("GET", "https://ham.spam/egg/") 312 | response = router.handler(request) 313 | assert response.status_code == 200 314 | assert response.json() == {"slug": "egg"} 315 | 316 | request = httpx.Request("POST", "https://example.org/") 317 | response = router.handler(request) 318 | assert response.status_code == 201 319 | assert response.json() == {"message": "OK"} 320 | 321 | 322 | def test_rollback(): 323 | router = Router() 324 | route = router.get("https://foo.bar/") % 404 325 | pattern = route.pattern 326 | assert route.name is None 327 | 328 | router.snapshot() # 1. get 404 329 | 330 | route.return_value = httpx.Response(200) 331 | router.post("https://foo.bar/").mock( 332 | side_effect=[httpx.Response(400), httpx.Response(201)] 333 | ) 334 | 335 | router.snapshot() # 2. get 200, post 336 | 337 | _route = router.get("https://foo.bar/", name="foobar") 338 | _route = router.get("https://foo.bar/baz/", name="foobar") 339 | assert _route is route 340 | assert route.name == "foobar" 341 | assert route.pattern != pattern 342 | route.return_value = httpx.Response(418) 343 | request = httpx.Request("GET", "https://foo.bar/baz/") 344 | response = router.handler(request) 345 | assert response.status_code == 418 346 | 347 | request = httpx.Request("POST", "https://foo.bar") 348 | response = router.handler(request) 349 | assert response.status_code == 400 350 | 351 | assert len(router.routes) == 2 352 | assert router.calls.call_count == 2 353 | assert route.call_count == 1 354 | assert route.return_value.status_code == 418 355 | 356 | router.snapshot() # 3. directly rollback, should be identical 357 | router.rollback() 358 | assert len(router.routes) == 2 359 | assert router.calls.call_count == 2 360 | assert route.call_count == 1 361 | assert route.return_value.status_code == 418 362 | 363 | router.patch("https://foo.bar/") 364 | assert len(router.routes) == 3 365 | 366 | route.rollback() # get 200 367 | 368 | assert router.calls.call_count == 2 369 | assert route.call_count == 0 370 | assert route.return_value.status_code == 200 371 | 372 | request = httpx.Request("GET", "https://foo.bar") 373 | response = router.handler(request) 374 | assert response.status_code == 200 375 | 376 | router.rollback() # 2. get 404, post 377 | 378 | request = httpx.Request("POST", "https://foo.bar") 379 | response = router.handler(request) 380 | assert response.status_code == 400 381 | assert len(router.routes) == 2 382 | 383 | router.rollback() # 1. get 404 384 | 385 | assert len(router.routes) == 1 386 | assert router.calls.call_count == 0 387 | assert route.return_value == None # noqa: E711 388 | 389 | router.rollback() # Empty initial state 390 | 391 | assert len(router.routes) == 0 392 | assert route.return_value == None # noqa: E711 393 | 394 | # Idempotent 395 | route.rollback() 396 | router.rollback() 397 | assert len(router.routes) == 0 398 | assert route.name is None 399 | assert route.pattern == pattern 400 | assert route.return_value is None 401 | 402 | 403 | def test_multiple_pattern_values_type_error(): 404 | router = Router() 405 | with pytest.raises(TypeError, match="Got multiple values for pattern 'method'"): 406 | router.post(method__in=("PUT", "PATCH")) 407 | with pytest.raises(TypeError, match="Got multiple values for pattern 'url'"): 408 | router.get("https://foo.bar", url__regex=r"https://example.org$") 409 | 410 | 411 | def test_routelist__add(): 412 | routes = RouteList() 413 | 414 | foobar = Route(method="PUT") 415 | routes.add(foobar, name="foobar") 416 | assert routes 417 | assert list(routes) == [foobar] 418 | assert routes["foobar"] == foobar 419 | assert routes["foobar"] is routes[0] 420 | 421 | hamspam = Route(method="POST") 422 | routes.add(hamspam, name="hamspam") 423 | assert list(routes) == [foobar, hamspam] 424 | assert routes["hamspam"] == hamspam 425 | 426 | 427 | def test_routelist__pop(): 428 | routes = RouteList() 429 | 430 | foobar = Route(method="GET") 431 | hamspam = Route(method="POST") 432 | routes.add(foobar, name="foobar") 433 | routes.add(hamspam, name="hamspam") 434 | assert list(routes) == [foobar, hamspam] 435 | 436 | _foobar = routes.pop("foobar") 437 | assert _foobar == foobar 438 | assert list(routes) == [hamspam] 439 | 440 | default = Route() 441 | route = routes.pop("egg", default) 442 | assert route is default 443 | assert list(routes) == [hamspam] 444 | 445 | with pytest.raises(KeyError): 446 | routes.pop("egg") 447 | 448 | 449 | def test_routelist__replaces_same_name_and_pattern(): 450 | routes = RouteList() 451 | 452 | foobar1 = Route(method="GET") 453 | routes.add(foobar1, name="foobar") 454 | assert list(routes) == [foobar1] 455 | 456 | foobar2 = Route(method="GET") 457 | routes.add(foobar2, name="foobar") 458 | assert list(routes) == [foobar2] 459 | assert routes[0] is foobar1 460 | 461 | 462 | def test_routelist__replaces_same_name_diff_pattern(): 463 | routes = RouteList() 464 | 465 | foobar1 = Route(method="GET") 466 | routes.add(foobar1, name="foobar") 467 | assert list(routes) == [foobar1] 468 | 469 | foobar2 = Route(method="POST") 470 | routes.add(foobar2, name="foobar") 471 | assert list(routes) == [foobar2] 472 | assert routes[0] is foobar1 473 | 474 | 475 | def test_routelist__replaces_same_pattern_no_name(): 476 | routes = RouteList() 477 | 478 | foobar1 = Route(method="GET") 479 | routes.add(foobar1) 480 | assert list(routes) == [foobar1] 481 | 482 | foobar2 = Route(method="GET") 483 | routes.add(foobar2, name="foobar") 484 | assert list(routes) == [foobar2] 485 | assert routes[0] is foobar1 486 | 487 | 488 | def test_routelist__replaces_same_pattern_diff_name(): 489 | routes = RouteList() 490 | 491 | foobar1 = Route(method="GET") 492 | routes.add(foobar1, name="name") 493 | assert list(routes) == [foobar1] 494 | 495 | foobar2 = Route(method="GET") 496 | routes.add(foobar2, name="foobar") 497 | assert list(routes) == [foobar2] 498 | assert routes[0] is foobar1 499 | 500 | 501 | def test_routelist__replaces_same_name_other_pattern_no_name(): 502 | routes = RouteList() 503 | 504 | foobar1 = Route(method="GET") 505 | routes.add(foobar1, name="foobar") 506 | assert list(routes) == [foobar1] 507 | 508 | hamspam = Route(method="POST") 509 | routes.add(hamspam) 510 | 511 | foobar2 = Route(method="POST") 512 | routes.add(foobar2, name="foobar") 513 | assert list(routes) == [foobar2] 514 | assert routes[0] is foobar1 515 | 516 | 517 | def test_routelist__replaces_same_name_other_pattern_other_name(): 518 | routes = RouteList() 519 | 520 | foobar1 = Route(method="GET") 521 | hamspam = Route(method="POST") 522 | 523 | routes.add(foobar1, name="foobar") 524 | routes.add(hamspam, name="hamspam") 525 | assert list(routes) == [foobar1, hamspam] 526 | 527 | foobar2 = Route(method="POST") 528 | routes.add(foobar2, name="foobar") 529 | assert list(routes) == [foobar2] 530 | assert routes["foobar"] is foobar1 531 | 532 | 533 | def test_routelist__unable_to_slice_assign(): 534 | routes = RouteList() 535 | with pytest.raises(TypeError, match="slice assign"): 536 | routes[0:1] = routes 537 | -------------------------------------------------------------------------------- /tests/test_stats.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import httpx 4 | import pytest 5 | 6 | import respx 7 | from respx.router import MockRouter 8 | 9 | 10 | async def test_named_route(): 11 | async with MockRouter(assert_all_called=False) as respx_mock: 12 | request = respx_mock.get("https://foo.bar/", name="foobar") 13 | assert "foobar" not in respx.routes 14 | assert "foobar" in respx_mock.routes 15 | assert respx_mock.routes["foobar"] is request 16 | assert respx_mock["foobar"] is request 17 | 18 | 19 | @respx.mock 20 | async def backend_test(): 21 | url = "https://foo.bar/1/" 22 | respx.get(re.compile("https://some.thing")) 23 | respx.delete("https://some.thing") 24 | 25 | foobar1 = respx.get(url, name="get_foobar") % dict(status_code=202, text="get") 26 | foobar2 = respx.delete(url, name="del_foobar") % dict(text="del") 27 | 28 | assert foobar1.called == False # noqa: E712 29 | assert foobar1.call_count == len(foobar1.calls) 30 | assert foobar1.call_count == 0 31 | with pytest.raises(IndexError): 32 | foobar1.calls.last 33 | assert respx.calls.call_count == len(respx.calls) 34 | assert respx.calls.call_count == 0 35 | 36 | with pytest.raises(AssertionError, match="Expected 'respx' to have been called"): 37 | respx.calls.assert_called_once() 38 | 39 | with pytest.raises(AssertionError, match="Expected ' None: 8 | expires = datetime.fromtimestamp(0, tz=timezone.utc) 9 | cookie = SetCookie( 10 | "foo", 11 | value="bar", 12 | path="/", 13 | domain=".example.com", 14 | expires=expires, 15 | max_age=44, 16 | http_only=True, 17 | same_site="None", 18 | partitioned=True, 19 | ) 20 | assert cookie == ( 21 | "Set-Cookie", 22 | ( 23 | "foo=bar; " 24 | "Path=/; " 25 | "Domain=.example.com; " 26 | "Expires=Thu, 01 Jan 1970 00:00:00 GMT; " 27 | "Max-Age=44; " 28 | "HttpOnly; " 29 | "SameSite=None; " 30 | "Secure; " 31 | "Partitioned" 32 | ), 33 | ) 34 | --------------------------------------------------------------------------------