replayx 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- replayx-0.1.0/.gitignore +26 -0
- replayx-0.1.0/CHANGELOG.md +27 -0
- replayx-0.1.0/LICENSE +21 -0
- replayx-0.1.0/PKG-INFO +261 -0
- replayx-0.1.0/README.md +202 -0
- replayx-0.1.0/pyproject.toml +85 -0
- replayx-0.1.0/src/replayx/__init__.py +33 -0
- replayx-0.1.0/src/replayx/_types.py +30 -0
- replayx-0.1.0/src/replayx/cassette.py +329 -0
- replayx-0.1.0/src/replayx/errors.py +41 -0
- replayx-0.1.0/src/replayx/matchers.py +107 -0
- replayx-0.1.0/src/replayx/py.typed +0 -0
- replayx-0.1.0/src/replayx/pytest_plugin.py +68 -0
- replayx-0.1.0/src/replayx/recorder.py +142 -0
- replayx-0.1.0/src/replayx/redaction.py +75 -0
- replayx-0.1.0/src/replayx/serializers.py +76 -0
- replayx-0.1.0/src/replayx/transport.py +66 -0
- replayx-0.1.0/tests/conftest.py +35 -0
- replayx-0.1.0/tests/test_cassette.py +105 -0
- replayx-0.1.0/tests/test_matchers.py +53 -0
- replayx-0.1.0/tests/test_pytest_plugin.py +29 -0
- replayx-0.1.0/tests/test_record_replay.py +118 -0
- replayx-0.1.0/tests/test_redaction.py +77 -0
- replayx-0.1.0/tests/test_serializers.py +32 -0
replayx-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
env/
|
|
14
|
+
|
|
15
|
+
# Tooling caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
coverage.xml
|
|
22
|
+
|
|
23
|
+
# Editors / OS
|
|
24
|
+
.idea/
|
|
25
|
+
.vscode/
|
|
26
|
+
.DS_Store
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-06-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Initial release.
|
|
13
|
+
- `use_cassette` context manager that transparently patches `httpx` to record
|
|
14
|
+
and replay interactions.
|
|
15
|
+
- Synchronous (`ReplayTransport`) and asynchronous (`AsyncReplayTransport`)
|
|
16
|
+
transports for explicit, no-magic usage.
|
|
17
|
+
- Four record modes: `once`, `new_episodes`, `none`, `all`.
|
|
18
|
+
- Configurable request matchers (`method`, `url`, `host`, `path`, `query`,
|
|
19
|
+
`headers`, `body`, ...).
|
|
20
|
+
- Secret redaction via `filter_headers`, `filter_query_params`, and
|
|
21
|
+
`before_record_request` / `before_record_response` hooks.
|
|
22
|
+
- JSON cassettes by default; optional YAML cassettes via the `yaml` extra.
|
|
23
|
+
- A `pytest` plugin exposing the `replayx_cassette` fixture and a
|
|
24
|
+
`--replayx-record` flag.
|
|
25
|
+
|
|
26
|
+
[Unreleased]: https://github.com/mkusiappiah/replayx/compare/v0.1.0...HEAD
|
|
27
|
+
[0.1.0]: https://github.com/mkusiappiah/replayx/releases/tag/v0.1.0
|
replayx-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Kusi-Appiah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
replayx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: replayx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Record and replay HTTP interactions for httpx — an async-native VCR for fast, deterministic tests.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mkusiappiah/replayx
|
|
6
|
+
Project-URL: Repository, https://github.com/mkusiappiah/replayx
|
|
7
|
+
Project-URL: Issues, https://github.com/mkusiappiah/replayx/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/mkusiappiah/replayx/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Michael Kusi-Appiah <appiah.michael@yahoo.com>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 Michael Kusi-Appiah
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: async,asyncio,http,httpx,mock,pytest,record,replay,testing,vcr
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Framework :: Pytest
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
44
|
+
Classifier: Topic :: Software Development :: Testing
|
|
45
|
+
Classifier: Topic :: Software Development :: Testing :: Mocking
|
|
46
|
+
Classifier: Typing :: Typed
|
|
47
|
+
Requires-Python: >=3.9
|
|
48
|
+
Requires-Dist: httpx>=0.23
|
|
49
|
+
Provides-Extra: dev
|
|
50
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
51
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
52
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
53
|
+
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
54
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
55
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
56
|
+
Provides-Extra: yaml
|
|
57
|
+
Requires-Dist: pyyaml>=6.0; extra == 'yaml'
|
|
58
|
+
Description-Content-Type: text/markdown
|
|
59
|
+
|
|
60
|
+
# replayx
|
|
61
|
+
|
|
62
|
+
Record and replay HTTP interactions for [httpx](https://www.python-httpx.org/). Run your tests fast and offline.
|
|
63
|
+
|
|
64
|
+
[](https://github.com/mkusiappiah/replayx/actions/workflows/ci.yml)
|
|
65
|
+
[](https://pypi.org/project/replayx/)
|
|
66
|
+
[](https://pypi.org/project/replayx/)
|
|
67
|
+
[](LICENSE)
|
|
68
|
+
|
|
69
|
+
replayx saves real HTTP responses to a cassette file on the first test run. Every later run reads from the cassette. No network calls. No flaky tests. No slow CI.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import httpx
|
|
73
|
+
from replayx import use_cassette
|
|
74
|
+
|
|
75
|
+
with use_cassette("cassettes/github.json"):
|
|
76
|
+
resp = httpx.get("https://api.github.com/users/octocat")
|
|
77
|
+
assert resp.json()["login"] == "octocat"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The first run records. Later runs replay.
|
|
81
|
+
|
|
82
|
+
## Why I built replayx
|
|
83
|
+
|
|
84
|
+
vcrpy brought record and replay to Python. vcrpy targets requests and the sync world. I built replayx for modern httpx code.
|
|
85
|
+
|
|
86
|
+
| Feature | replayx | vcrpy |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| Async httpx.AsyncClient | yes | limited |
|
|
89
|
+
| Built for httpx | yes | through patches |
|
|
90
|
+
| Zero deps beyond httpx | yes (JSON) | needs PyYAML |
|
|
91
|
+
| Secret redaction for committed cassettes | yes | partial |
|
|
92
|
+
| Explicit transport API, no patching | yes | no |
|
|
93
|
+
| Modern typing with py.typed | yes | no |
|
|
94
|
+
|
|
95
|
+
## Install
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install replayx
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Add YAML cassettes:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install "replayx[yaml]"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
replayx needs Python 3.9 or newer and httpx 0.23 or newer.
|
|
108
|
+
|
|
109
|
+
## Usage
|
|
110
|
+
|
|
111
|
+
### Patch httpx with use_cassette
|
|
112
|
+
|
|
113
|
+
use_cassette patches httpx for the block. Your existing client code runs without changes. Sync and async both work.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import httpx
|
|
117
|
+
from replayx import use_cassette
|
|
118
|
+
|
|
119
|
+
async def fetch():
|
|
120
|
+
async with httpx.AsyncClient() as client:
|
|
121
|
+
return await client.get("https://api.example.com/data")
|
|
122
|
+
|
|
123
|
+
with use_cassette("cassettes/data.json"):
|
|
124
|
+
resp = await fetch()
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Build a transport yourself
|
|
128
|
+
|
|
129
|
+
Prefer no patching? Build a transport and pass the transport to your client. Nothing gets monkeypatched.
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
import httpx
|
|
133
|
+
from replayx import Cassette
|
|
134
|
+
|
|
135
|
+
cassette = Cassette.load("cassettes/data.json", record_mode="once")
|
|
136
|
+
|
|
137
|
+
with httpx.Client(transport=cassette.sync_transport()) as client:
|
|
138
|
+
resp = client.get("https://api.example.com/data")
|
|
139
|
+
|
|
140
|
+
cassette.save()
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Use `cassette.async_transport()` with `httpx.AsyncClient` for async code.
|
|
144
|
+
|
|
145
|
+
### The pytest plugin
|
|
146
|
+
|
|
147
|
+
The plugin gives each test an auto-named cassette at `<test-dir>/cassettes/<test-name>.json`.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import httpx
|
|
151
|
+
|
|
152
|
+
def test_octocat(replayx_cassette):
|
|
153
|
+
with replayx_cassette():
|
|
154
|
+
resp = httpx.get("https://api.github.com/users/octocat")
|
|
155
|
+
assert resp.status_code == 200
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Re-record a whole run from the command line:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
pytest --replayx-record=all
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Set per-test defaults with the marker:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
import pytest
|
|
168
|
+
|
|
169
|
+
@pytest.mark.replayx(match_on=("method", "url", "body"), filter_headers=["authorization"])
|
|
170
|
+
def test_create(replayx_cassette):
|
|
171
|
+
with replayx_cassette():
|
|
172
|
+
...
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Record modes
|
|
176
|
+
|
|
177
|
+
| Mode | What happens |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| once (default) | Replay an existing cassette. Record everything when no cassette exists. A new request against an existing cassette raises an error. |
|
|
180
|
+
| new_episodes | Replay matches and append new interactions. |
|
|
181
|
+
| none | Replay only. No network. No writes. Good for CI. |
|
|
182
|
+
| all | Always reach the real backend and overwrite the cassette. Use to re-record. |
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
with use_cassette("cassettes/api.json", record_mode="none"):
|
|
186
|
+
...
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Match requests
|
|
190
|
+
|
|
191
|
+
Requests match on method and url by default. Query order does not affect matching. Change the rules with match_on.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
with use_cassette("cassettes/api.json", match_on=("method", "path", "body")):
|
|
195
|
+
...
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Available matchers: method, scheme, host, port, path, query, url (alias uri), headers, body.
|
|
199
|
+
|
|
200
|
+
## Redact secrets
|
|
201
|
+
|
|
202
|
+
Commit cassettes without leaking credentials. Redaction runs at record time. The live response your code receives stays intact.
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
with use_cassette(
|
|
206
|
+
"cassettes/api.json",
|
|
207
|
+
filter_headers=["authorization", "set-cookie"],
|
|
208
|
+
filter_query_params=["api_key", "token"],
|
|
209
|
+
):
|
|
210
|
+
...
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Use hooks for full control. Return a changed recording, or return None to skip the recording.
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from dataclasses import replace
|
|
217
|
+
|
|
218
|
+
def scrub_body(response):
|
|
219
|
+
return replace(response, body=b'{"token": "REDACTED"}')
|
|
220
|
+
|
|
221
|
+
with use_cassette("cassettes/api.json", before_record_response=scrub_body):
|
|
222
|
+
...
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Cassette format
|
|
226
|
+
|
|
227
|
+
Cassettes use plain JSON. YAML works with the yaml extra. Both read well in code review.
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"version": 1,
|
|
232
|
+
"recorded_with": "replayx/0.1.0",
|
|
233
|
+
"interactions": [
|
|
234
|
+
{
|
|
235
|
+
"request": { "method": "GET", "url": "https://api.example.com/data", "headers": [], "body": null },
|
|
236
|
+
"response": { "status_code": 200, "headers": [["content-type", "application/json"]], "body": { "text": "{\"ok\":true}" } }
|
|
237
|
+
}
|
|
238
|
+
]
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
replayx stores binary bodies as base64.
|
|
243
|
+
|
|
244
|
+
## Contribute
|
|
245
|
+
|
|
246
|
+
I welcome contributions. Set up a dev environment:
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
git clone https://github.com/mkusiappiah/replayx
|
|
250
|
+
cd replayx
|
|
251
|
+
pip install -e ".[dev]"
|
|
252
|
+
pytest
|
|
253
|
+
ruff check .
|
|
254
|
+
mypy
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Open an issue before large changes.
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
MIT. See [LICENSE](LICENSE).
|
replayx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# replayx
|
|
2
|
+
|
|
3
|
+
Record and replay HTTP interactions for [httpx](https://www.python-httpx.org/). Run your tests fast and offline.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/mkusiappiah/replayx/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/replayx/)
|
|
7
|
+
[](https://pypi.org/project/replayx/)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
replayx saves real HTTP responses to a cassette file on the first test run. Every later run reads from the cassette. No network calls. No flaky tests. No slow CI.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
import httpx
|
|
14
|
+
from replayx import use_cassette
|
|
15
|
+
|
|
16
|
+
with use_cassette("cassettes/github.json"):
|
|
17
|
+
resp = httpx.get("https://api.github.com/users/octocat")
|
|
18
|
+
assert resp.json()["login"] == "octocat"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The first run records. Later runs replay.
|
|
22
|
+
|
|
23
|
+
## Why I built replayx
|
|
24
|
+
|
|
25
|
+
vcrpy brought record and replay to Python. vcrpy targets requests and the sync world. I built replayx for modern httpx code.
|
|
26
|
+
|
|
27
|
+
| Feature | replayx | vcrpy |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| Async httpx.AsyncClient | yes | limited |
|
|
30
|
+
| Built for httpx | yes | through patches |
|
|
31
|
+
| Zero deps beyond httpx | yes (JSON) | needs PyYAML |
|
|
32
|
+
| Secret redaction for committed cassettes | yes | partial |
|
|
33
|
+
| Explicit transport API, no patching | yes | no |
|
|
34
|
+
| Modern typing with py.typed | yes | no |
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install replayx
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Add YAML cassettes:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install "replayx[yaml]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
replayx needs Python 3.9 or newer and httpx 0.23 or newer.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### Patch httpx with use_cassette
|
|
53
|
+
|
|
54
|
+
use_cassette patches httpx for the block. Your existing client code runs without changes. Sync and async both work.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import httpx
|
|
58
|
+
from replayx import use_cassette
|
|
59
|
+
|
|
60
|
+
async def fetch():
|
|
61
|
+
async with httpx.AsyncClient() as client:
|
|
62
|
+
return await client.get("https://api.example.com/data")
|
|
63
|
+
|
|
64
|
+
with use_cassette("cassettes/data.json"):
|
|
65
|
+
resp = await fetch()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Build a transport yourself
|
|
69
|
+
|
|
70
|
+
Prefer no patching? Build a transport and pass the transport to your client. Nothing gets monkeypatched.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import httpx
|
|
74
|
+
from replayx import Cassette
|
|
75
|
+
|
|
76
|
+
cassette = Cassette.load("cassettes/data.json", record_mode="once")
|
|
77
|
+
|
|
78
|
+
with httpx.Client(transport=cassette.sync_transport()) as client:
|
|
79
|
+
resp = client.get("https://api.example.com/data")
|
|
80
|
+
|
|
81
|
+
cassette.save()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Use `cassette.async_transport()` with `httpx.AsyncClient` for async code.
|
|
85
|
+
|
|
86
|
+
### The pytest plugin
|
|
87
|
+
|
|
88
|
+
The plugin gives each test an auto-named cassette at `<test-dir>/cassettes/<test-name>.json`.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import httpx
|
|
92
|
+
|
|
93
|
+
def test_octocat(replayx_cassette):
|
|
94
|
+
with replayx_cassette():
|
|
95
|
+
resp = httpx.get("https://api.github.com/users/octocat")
|
|
96
|
+
assert resp.status_code == 200
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Re-record a whole run from the command line:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pytest --replayx-record=all
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Set per-test defaults with the marker:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import pytest
|
|
109
|
+
|
|
110
|
+
@pytest.mark.replayx(match_on=("method", "url", "body"), filter_headers=["authorization"])
|
|
111
|
+
def test_create(replayx_cassette):
|
|
112
|
+
with replayx_cassette():
|
|
113
|
+
...
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Record modes
|
|
117
|
+
|
|
118
|
+
| Mode | What happens |
|
|
119
|
+
| --- | --- |
|
|
120
|
+
| once (default) | Replay an existing cassette. Record everything when no cassette exists. A new request against an existing cassette raises an error. |
|
|
121
|
+
| new_episodes | Replay matches and append new interactions. |
|
|
122
|
+
| none | Replay only. No network. No writes. Good for CI. |
|
|
123
|
+
| all | Always reach the real backend and overwrite the cassette. Use to re-record. |
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
with use_cassette("cassettes/api.json", record_mode="none"):
|
|
127
|
+
...
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Match requests
|
|
131
|
+
|
|
132
|
+
Requests match on method and url by default. Query order does not affect matching. Change the rules with match_on.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
with use_cassette("cassettes/api.json", match_on=("method", "path", "body")):
|
|
136
|
+
...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Available matchers: method, scheme, host, port, path, query, url (alias uri), headers, body.
|
|
140
|
+
|
|
141
|
+
## Redact secrets
|
|
142
|
+
|
|
143
|
+
Commit cassettes without leaking credentials. Redaction runs at record time. The live response your code receives stays intact.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
with use_cassette(
|
|
147
|
+
"cassettes/api.json",
|
|
148
|
+
filter_headers=["authorization", "set-cookie"],
|
|
149
|
+
filter_query_params=["api_key", "token"],
|
|
150
|
+
):
|
|
151
|
+
...
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Use hooks for full control. Return a changed recording, or return None to skip the recording.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from dataclasses import replace
|
|
158
|
+
|
|
159
|
+
def scrub_body(response):
|
|
160
|
+
return replace(response, body=b'{"token": "REDACTED"}')
|
|
161
|
+
|
|
162
|
+
with use_cassette("cassettes/api.json", before_record_response=scrub_body):
|
|
163
|
+
...
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Cassette format
|
|
167
|
+
|
|
168
|
+
Cassettes use plain JSON. YAML works with the yaml extra. Both read well in code review.
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"version": 1,
|
|
173
|
+
"recorded_with": "replayx/0.1.0",
|
|
174
|
+
"interactions": [
|
|
175
|
+
{
|
|
176
|
+
"request": { "method": "GET", "url": "https://api.example.com/data", "headers": [], "body": null },
|
|
177
|
+
"response": { "status_code": 200, "headers": [["content-type", "application/json"]], "body": { "text": "{\"ok\":true}" } }
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
replayx stores binary bodies as base64.
|
|
184
|
+
|
|
185
|
+
## Contribute
|
|
186
|
+
|
|
187
|
+
I welcome contributions. Set up a dev environment:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
git clone https://github.com/mkusiappiah/replayx
|
|
191
|
+
cd replayx
|
|
192
|
+
pip install -e ".[dev]"
|
|
193
|
+
pytest
|
|
194
|
+
ruff check .
|
|
195
|
+
mypy
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Open an issue before large changes.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "replayx"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Record and replay HTTP interactions for httpx — an async-native VCR for fast, deterministic tests."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Michael Kusi-Appiah", email = "appiah.michael@yahoo.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"httpx",
|
|
15
|
+
"testing",
|
|
16
|
+
"vcr",
|
|
17
|
+
"mock",
|
|
18
|
+
"http",
|
|
19
|
+
"record",
|
|
20
|
+
"replay",
|
|
21
|
+
"pytest",
|
|
22
|
+
"async",
|
|
23
|
+
"asyncio",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.9",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
"Programming Language :: Python :: 3.11",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Programming Language :: Python :: 3.13",
|
|
36
|
+
"Framework :: Pytest",
|
|
37
|
+
"Topic :: Software Development :: Testing",
|
|
38
|
+
"Topic :: Software Development :: Testing :: Mocking",
|
|
39
|
+
"Typing :: Typed",
|
|
40
|
+
]
|
|
41
|
+
dependencies = ["httpx>=0.23"]
|
|
42
|
+
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
yaml = ["PyYAML>=6.0"]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=7.4",
|
|
47
|
+
"pytest-asyncio>=0.23",
|
|
48
|
+
"PyYAML>=6.0",
|
|
49
|
+
"ruff>=0.5",
|
|
50
|
+
"mypy>=1.8",
|
|
51
|
+
"types-PyYAML",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[project.urls]
|
|
55
|
+
Homepage = "https://github.com/mkusiappiah/replayx"
|
|
56
|
+
Repository = "https://github.com/mkusiappiah/replayx"
|
|
57
|
+
Issues = "https://github.com/mkusiappiah/replayx/issues"
|
|
58
|
+
Changelog = "https://github.com/mkusiappiah/replayx/blob/main/CHANGELOG.md"
|
|
59
|
+
|
|
60
|
+
[project.entry-points.pytest11]
|
|
61
|
+
replayx = "replayx.pytest_plugin"
|
|
62
|
+
|
|
63
|
+
[tool.hatch.version]
|
|
64
|
+
path = "src/replayx/__init__.py"
|
|
65
|
+
|
|
66
|
+
[tool.hatch.build.targets.wheel]
|
|
67
|
+
packages = ["src/replayx"]
|
|
68
|
+
|
|
69
|
+
[tool.hatch.build.targets.sdist]
|
|
70
|
+
include = ["src/replayx", "tests", "README.md", "CHANGELOG.md", "LICENSE"]
|
|
71
|
+
|
|
72
|
+
[tool.pytest.ini_options]
|
|
73
|
+
asyncio_mode = "auto"
|
|
74
|
+
testpaths = ["tests"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff]
|
|
77
|
+
line-length = 100
|
|
78
|
+
target-version = "py39"
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint]
|
|
81
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
82
|
+
|
|
83
|
+
[tool.mypy]
|
|
84
|
+
strict = true
|
|
85
|
+
files = ["src/replayx"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""replayx — record & replay HTTP interactions for httpx.
|
|
2
|
+
|
|
3
|
+
replayx is an async-native "VCR" for `httpx`: it records real HTTP responses
|
|
4
|
+
to a cassette file the first time your tests run, then replays them on every
|
|
5
|
+
subsequent run so tests are fast, offline and deterministic.
|
|
6
|
+
|
|
7
|
+
See https://github.com/mkusiappiah/replayx for documentation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from ._types import RecordMode
|
|
15
|
+
from .cassette import Cassette, Interaction, RecordedRequest, RecordedResponse
|
|
16
|
+
from .errors import CassetteFormatError, ReplayxError, UnhandledRequestError
|
|
17
|
+
from .recorder import use_cassette
|
|
18
|
+
from .transport import AsyncReplayTransport, ReplayTransport
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AsyncReplayTransport",
|
|
22
|
+
"Cassette",
|
|
23
|
+
"CassetteFormatError",
|
|
24
|
+
"Interaction",
|
|
25
|
+
"RecordMode",
|
|
26
|
+
"RecordedRequest",
|
|
27
|
+
"RecordedResponse",
|
|
28
|
+
"ReplayTransport",
|
|
29
|
+
"ReplayxError",
|
|
30
|
+
"UnhandledRequestError",
|
|
31
|
+
"__version__",
|
|
32
|
+
"use_cassette",
|
|
33
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared types and enumerations for replayx."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RecordMode(str, Enum):
|
|
9
|
+
"""Controls how a cassette records and replays interactions.
|
|
10
|
+
|
|
11
|
+
The semantics mirror the well-understood VCR record modes:
|
|
12
|
+
|
|
13
|
+
* ``ONCE`` — Replay interactions from an existing cassette. If the cassette
|
|
14
|
+
file does not yet exist, record everything. Once a cassette exists, new
|
|
15
|
+
(unmatched) requests raise :class:`~replayx.UnhandledRequestError`.
|
|
16
|
+
* ``NEW_EPISODES`` — Replay matching interactions and record any new ones,
|
|
17
|
+
appending them to the cassette.
|
|
18
|
+
* ``NONE`` — Replay only. Never touch the network and never write. New
|
|
19
|
+
requests raise :class:`~replayx.UnhandledRequestError`.
|
|
20
|
+
* ``ALL`` — Never replay. Always hit the real backend and (re)record,
|
|
21
|
+
overwriting the cassette.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
ONCE = "once"
|
|
25
|
+
NEW_EPISODES = "new_episodes"
|
|
26
|
+
NONE = "none"
|
|
27
|
+
ALL = "all"
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str: # pragma: no cover - cosmetic
|
|
30
|
+
return self.value
|