├── .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 |
3 |
4 |
5 | RESPX - Mock HTTPX with awesome request patterns and response side effects.
6 |
7 |
8 | ---
9 |
10 | [](https://github.com/lundberg/respx/actions/workflows/test.yml)
11 | [](https://codecov.io/gh/lundberg/respx)
12 | [](https://pypi.org/project/respx/)
13 | [](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 |
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 | [](https://github.com/lundberg/respx/actions/workflows/test.yml) [](https://codecov.io/gh/lundberg/respx) [](https://pypi.org/project/respx/) [](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 |
--------------------------------------------------------------------------------