http-snapshot 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.
- http_snapshot-0.1.0/PKG-INFO +241 -0
- http_snapshot-0.1.0/README.md +230 -0
- http_snapshot-0.1.0/pyproject.toml +30 -0
- http_snapshot-0.1.0/setup.cfg +4 -0
- http_snapshot-0.1.0/src/http_snapshot/__init__.py +0 -0
- http_snapshot-0.1.0/src/http_snapshot/_integrations/__init__.py +0 -0
- http_snapshot-0.1.0/src/http_snapshot/_integrations/_httpx.py +76 -0
- http_snapshot-0.1.0/src/http_snapshot/_integrations/_requests.py +105 -0
- http_snapshot-0.1.0/src/http_snapshot/_models.py +17 -0
- http_snapshot-0.1.0/src/http_snapshot/_pytest_plugin.py +84 -0
- http_snapshot-0.1.0/src/http_snapshot/_serializer.py +154 -0
- http_snapshot-0.1.0/src/http_snapshot.egg-info/PKG-INFO +241 -0
- http_snapshot-0.1.0/src/http_snapshot.egg-info/SOURCES.txt +15 -0
- http_snapshot-0.1.0/src/http_snapshot.egg-info/dependency_links.txt +1 -0
- http_snapshot-0.1.0/src/http_snapshot.egg-info/entry_points.txt +2 -0
- http_snapshot-0.1.0/src/http_snapshot.egg-info/requires.txt +6 -0
- http_snapshot-0.1.0/src/http_snapshot.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: http-snapshot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: http-snapshot is a pytest plugin that snapshots requests made with popular Python HTTP clients.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: httpx
|
|
8
|
+
Requires-Dist: httpx>=0.28.1; extra == "httpx"
|
|
9
|
+
Provides-Extra: requests
|
|
10
|
+
Requires-Dist: requests>=2.32.5; extra == "requests"
|
|
11
|
+
|
|
12
|
+
# http-snapshot
|
|
13
|
+
|
|
14
|
+
`http-snapshot` is a pytest plugin that captures and snapshots HTTP requests/responses made with popular Python HTTP clients like `httpx` and `requests`. It uses [inline-snapshot](https://github.com/15r10nk/inline-snapshot) to store HTTP interactions as JSON files, enabling fast and reliable HTTP testing without making actual network calls.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- ๐ **Support for multiple HTTP clients**: `httpx` (async) and `requests` (sync)
|
|
19
|
+
- ๐ธ **Automatic HTTP interaction capture**: Records both requests and responses
|
|
20
|
+
- ๐ **Security-aware**: Automatically excludes sensitive headers like authorization and cookies
|
|
21
|
+
- โ๏ธ **Configurable**: Control what gets captured and what gets excluded
|
|
22
|
+
- ๐งช **pytest integration**: Works seamlessly with your existing pytest test suite
|
|
23
|
+
- ๐ **External snapshots**: Stores snapshots in organized JSON files
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install http-snapshot
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For specific HTTP client support:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# For httpx support
|
|
35
|
+
pip install http-snapshot[httpx]
|
|
36
|
+
|
|
37
|
+
# For requests support
|
|
38
|
+
pip install http-snapshot[requests]
|
|
39
|
+
|
|
40
|
+
# For both
|
|
41
|
+
pip install http-snapshot[httpx,requests]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
### Using with httpx (async)
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import httpx
|
|
50
|
+
import pytest
|
|
51
|
+
import inline_snapshot
|
|
52
|
+
|
|
53
|
+
@pytest.mark.anyio
|
|
54
|
+
@pytest.mark.parametrize(
|
|
55
|
+
"http_snapshot",
|
|
56
|
+
[inline_snapshot.external("uuid:my-test-snapshot.json")],
|
|
57
|
+
)
|
|
58
|
+
async def test_api_call(snapshot_httpx_client: httpx.AsyncClient) -> None:
|
|
59
|
+
# This will be captured on first run, replayed on subsequent runs
|
|
60
|
+
response = await snapshot_httpx_client.get("https://api.example.com/users")
|
|
61
|
+
assert response.status_code == 200
|
|
62
|
+
assert "users" in response.json()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Using with requests (sync)
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import requests
|
|
69
|
+
import pytest
|
|
70
|
+
import inline_snapshot
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize(
|
|
73
|
+
"http_snapshot",
|
|
74
|
+
[inline_snapshot.external("uuid:my-test-snapshot.json")],
|
|
75
|
+
)
|
|
76
|
+
def test_api_call(snapshot_requests_session: requests.Session) -> None:
|
|
77
|
+
# This will be captured on first run, replayed on subsequent runs
|
|
78
|
+
response = snapshot_requests_session.get("https://api.example.com/users")
|
|
79
|
+
assert response.status_code == 200
|
|
80
|
+
assert "users" in response.json()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## How It Works
|
|
84
|
+
|
|
85
|
+
### Live Mode vs Replay Mode
|
|
86
|
+
|
|
87
|
+
The plugin operates in two modes:
|
|
88
|
+
|
|
89
|
+
1. **Live Mode**: When `HTTP_SNAPSHOT_LIVE=1` is set, actual HTTP requests are made and responses are captured
|
|
90
|
+
2. **Replay Mode**: When not in live mode, previously captured responses are replayed
|
|
91
|
+
|
|
92
|
+
### Running in Live Mode
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Capture new snapshots
|
|
96
|
+
HTTP_SNAPSHOT_LIVE=1 pytest tests/
|
|
97
|
+
|
|
98
|
+
# Replay existing snapshots (default)
|
|
99
|
+
pytest tests/
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Configuration Options
|
|
103
|
+
|
|
104
|
+
You can customize what gets captured using `SnapshotSerializerOptions`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import pytest
|
|
108
|
+
import inline_snapshot
|
|
109
|
+
from http_snapshot._serializer import SnapshotSerializerOptions
|
|
110
|
+
|
|
111
|
+
@pytest.mark.parametrize(
|
|
112
|
+
"http_snapshot, http_snapshot_serializer_options",
|
|
113
|
+
[
|
|
114
|
+
(
|
|
115
|
+
inline_snapshot.external("uuid:my-test-snapshot.json"),
|
|
116
|
+
SnapshotSerializerOptions(
|
|
117
|
+
exclude_request_headers=["X-API-Key"],
|
|
118
|
+
include_request=True, # Include request details in snapshot
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
def test_with_custom_options(
|
|
124
|
+
snapshot_requests_session: requests.Session,
|
|
125
|
+
http_snapshot_serializer_options: SnapshotSerializerOptions,
|
|
126
|
+
) -> None:
|
|
127
|
+
response = snapshot_requests_session.get(
|
|
128
|
+
"https://api.example.com/protected",
|
|
129
|
+
headers={"X-API-Key": "secret-key"}
|
|
130
|
+
)
|
|
131
|
+
assert response.status_code == 200
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Available Options
|
|
135
|
+
|
|
136
|
+
- `include_request`: Whether to include request details in snapshots (default: `True`)
|
|
137
|
+
- `exclude_request_headers`: List of request headers to exclude from snapshots
|
|
138
|
+
- `exclude_response_headers`: List of response headers to exclude from snapshots
|
|
139
|
+
|
|
140
|
+
By default, the following sensitive headers are always excluded:
|
|
141
|
+
|
|
142
|
+
- **Request**: `authorization`, `cookie`
|
|
143
|
+
- **Response**: `set-cookie`, `www-authenticate`, `proxy-authenticate`, `authentication-info`, `proxy-authentication-info`, `transfer-encoding`, `content-encoding`
|
|
144
|
+
|
|
145
|
+
## Snapshot Format
|
|
146
|
+
|
|
147
|
+
Snapshots are stored as JSON files with the following structure:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
[
|
|
151
|
+
{
|
|
152
|
+
"request": {
|
|
153
|
+
"method": "GET",
|
|
154
|
+
"url": "https://api.example.com/users",
|
|
155
|
+
"headers": {
|
|
156
|
+
"host": "api.example.com",
|
|
157
|
+
"accept": "*/*",
|
|
158
|
+
"accept-encoding": "gzip, deflate",
|
|
159
|
+
"connection": "keep-alive",
|
|
160
|
+
"user-agent": "python-httpx/0.28.1"
|
|
161
|
+
},
|
|
162
|
+
"body": ""
|
|
163
|
+
},
|
|
164
|
+
"response": {
|
|
165
|
+
"status_code": 200,
|
|
166
|
+
"headers": {
|
|
167
|
+
"date": "Thu, 21 Aug 2025 15:49:45 GMT",
|
|
168
|
+
"content-type": "application/json; charset=utf-8",
|
|
169
|
+
"connection": "keep-alive",
|
|
170
|
+
"server": "nginx/1.18.0"
|
|
171
|
+
},
|
|
172
|
+
"body": "{\n \"users\": [\n {\n \"id\": 1,\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\"\n }\n ]\n}"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Content Encoding
|
|
179
|
+
|
|
180
|
+
The plugin intelligently handles different content types:
|
|
181
|
+
|
|
182
|
+
- **JSON**: Formatted with proper indentation for readability
|
|
183
|
+
- **Text**: Stored as UTF-8 strings
|
|
184
|
+
- **Binary**: Base64 encoded
|
|
185
|
+
|
|
186
|
+
## Advanced Examples
|
|
187
|
+
|
|
188
|
+
### Testing API with Multiple Requests
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
@pytest.mark.anyio
|
|
192
|
+
@pytest.mark.parametrize(
|
|
193
|
+
"http_snapshot",
|
|
194
|
+
[inline_snapshot.external("uuid:multi-request-test.json")],
|
|
195
|
+
)
|
|
196
|
+
async def test_multiple_requests(snapshot_httpx_client: httpx.AsyncClient) -> None:
|
|
197
|
+
# Create a user
|
|
198
|
+
create_response = await snapshot_httpx_client.post(
|
|
199
|
+
"https://api.example.com/users",
|
|
200
|
+
json={"name": "Alice", "email": "alice@example.com"}
|
|
201
|
+
)
|
|
202
|
+
assert create_response.status_code == 201
|
|
203
|
+
user_id = create_response.json()["id"]
|
|
204
|
+
|
|
205
|
+
# Fetch the user
|
|
206
|
+
get_response = await snapshot_httpx_client.get(
|
|
207
|
+
f"https://api.example.com/users/{user_id}"
|
|
208
|
+
)
|
|
209
|
+
assert get_response.status_code == 200
|
|
210
|
+
assert get_response.json()["name"] == "Alice"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Testing with Authentication
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
@pytest.mark.parametrize(
|
|
217
|
+
"http_snapshot, http_snapshot_serializer_options",
|
|
218
|
+
[
|
|
219
|
+
(
|
|
220
|
+
inline_snapshot.external("uuid:auth-test.json"),
|
|
221
|
+
SnapshotSerializerOptions(exclude_request_headers=["Authorization"]),
|
|
222
|
+
),
|
|
223
|
+
],
|
|
224
|
+
)
|
|
225
|
+
def test_authenticated_request(
|
|
226
|
+
snapshot_requests_session: requests.Session,
|
|
227
|
+
http_snapshot_serializer_options,
|
|
228
|
+
) -> None:
|
|
229
|
+
# The Authorization header will be excluded from the snapshot
|
|
230
|
+
response = snapshot_requests_session.get(
|
|
231
|
+
"https://api.example.com/profile",
|
|
232
|
+
headers={"Authorization": "Bearer secret-token"}
|
|
233
|
+
)
|
|
234
|
+
assert response.status_code == 200
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Best Practices
|
|
238
|
+
|
|
239
|
+
1. **Exclude sensitive data**: Always exclude headers containing secrets, tokens, or personal data
|
|
240
|
+
2. **Review snapshots**: Check generated snapshot files into version control and review changes
|
|
241
|
+
3. **Use live mode sparingly**: Only run in live mode when you need to update snapshots
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# http-snapshot
|
|
2
|
+
|
|
3
|
+
`http-snapshot` is a pytest plugin that captures and snapshots HTTP requests/responses made with popular Python HTTP clients like `httpx` and `requests`. It uses [inline-snapshot](https://github.com/15r10nk/inline-snapshot) to store HTTP interactions as JSON files, enabling fast and reliable HTTP testing without making actual network calls.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- ๐ **Support for multiple HTTP clients**: `httpx` (async) and `requests` (sync)
|
|
8
|
+
- ๐ธ **Automatic HTTP interaction capture**: Records both requests and responses
|
|
9
|
+
- ๐ **Security-aware**: Automatically excludes sensitive headers like authorization and cookies
|
|
10
|
+
- โ๏ธ **Configurable**: Control what gets captured and what gets excluded
|
|
11
|
+
- ๐งช **pytest integration**: Works seamlessly with your existing pytest test suite
|
|
12
|
+
- ๐ **External snapshots**: Stores snapshots in organized JSON files
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install http-snapshot
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For specific HTTP client support:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# For httpx support
|
|
24
|
+
pip install http-snapshot[httpx]
|
|
25
|
+
|
|
26
|
+
# For requests support
|
|
27
|
+
pip install http-snapshot[requests]
|
|
28
|
+
|
|
29
|
+
# For both
|
|
30
|
+
pip install http-snapshot[httpx,requests]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
### Using with httpx (async)
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import httpx
|
|
39
|
+
import pytest
|
|
40
|
+
import inline_snapshot
|
|
41
|
+
|
|
42
|
+
@pytest.mark.anyio
|
|
43
|
+
@pytest.mark.parametrize(
|
|
44
|
+
"http_snapshot",
|
|
45
|
+
[inline_snapshot.external("uuid:my-test-snapshot.json")],
|
|
46
|
+
)
|
|
47
|
+
async def test_api_call(snapshot_httpx_client: httpx.AsyncClient) -> None:
|
|
48
|
+
# This will be captured on first run, replayed on subsequent runs
|
|
49
|
+
response = await snapshot_httpx_client.get("https://api.example.com/users")
|
|
50
|
+
assert response.status_code == 200
|
|
51
|
+
assert "users" in response.json()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Using with requests (sync)
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import requests
|
|
58
|
+
import pytest
|
|
59
|
+
import inline_snapshot
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize(
|
|
62
|
+
"http_snapshot",
|
|
63
|
+
[inline_snapshot.external("uuid:my-test-snapshot.json")],
|
|
64
|
+
)
|
|
65
|
+
def test_api_call(snapshot_requests_session: requests.Session) -> None:
|
|
66
|
+
# This will be captured on first run, replayed on subsequent runs
|
|
67
|
+
response = snapshot_requests_session.get("https://api.example.com/users")
|
|
68
|
+
assert response.status_code == 200
|
|
69
|
+
assert "users" in response.json()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## How It Works
|
|
73
|
+
|
|
74
|
+
### Live Mode vs Replay Mode
|
|
75
|
+
|
|
76
|
+
The plugin operates in two modes:
|
|
77
|
+
|
|
78
|
+
1. **Live Mode**: When `HTTP_SNAPSHOT_LIVE=1` is set, actual HTTP requests are made and responses are captured
|
|
79
|
+
2. **Replay Mode**: When not in live mode, previously captured responses are replayed
|
|
80
|
+
|
|
81
|
+
### Running in Live Mode
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Capture new snapshots
|
|
85
|
+
HTTP_SNAPSHOT_LIVE=1 pytest tests/
|
|
86
|
+
|
|
87
|
+
# Replay existing snapshots (default)
|
|
88
|
+
pytest tests/
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Configuration Options
|
|
92
|
+
|
|
93
|
+
You can customize what gets captured using `SnapshotSerializerOptions`:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import pytest
|
|
97
|
+
import inline_snapshot
|
|
98
|
+
from http_snapshot._serializer import SnapshotSerializerOptions
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize(
|
|
101
|
+
"http_snapshot, http_snapshot_serializer_options",
|
|
102
|
+
[
|
|
103
|
+
(
|
|
104
|
+
inline_snapshot.external("uuid:my-test-snapshot.json"),
|
|
105
|
+
SnapshotSerializerOptions(
|
|
106
|
+
exclude_request_headers=["X-API-Key"],
|
|
107
|
+
include_request=True, # Include request details in snapshot
|
|
108
|
+
),
|
|
109
|
+
),
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
def test_with_custom_options(
|
|
113
|
+
snapshot_requests_session: requests.Session,
|
|
114
|
+
http_snapshot_serializer_options: SnapshotSerializerOptions,
|
|
115
|
+
) -> None:
|
|
116
|
+
response = snapshot_requests_session.get(
|
|
117
|
+
"https://api.example.com/protected",
|
|
118
|
+
headers={"X-API-Key": "secret-key"}
|
|
119
|
+
)
|
|
120
|
+
assert response.status_code == 200
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Available Options
|
|
124
|
+
|
|
125
|
+
- `include_request`: Whether to include request details in snapshots (default: `True`)
|
|
126
|
+
- `exclude_request_headers`: List of request headers to exclude from snapshots
|
|
127
|
+
- `exclude_response_headers`: List of response headers to exclude from snapshots
|
|
128
|
+
|
|
129
|
+
By default, the following sensitive headers are always excluded:
|
|
130
|
+
|
|
131
|
+
- **Request**: `authorization`, `cookie`
|
|
132
|
+
- **Response**: `set-cookie`, `www-authenticate`, `proxy-authenticate`, `authentication-info`, `proxy-authentication-info`, `transfer-encoding`, `content-encoding`
|
|
133
|
+
|
|
134
|
+
## Snapshot Format
|
|
135
|
+
|
|
136
|
+
Snapshots are stored as JSON files with the following structure:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
[
|
|
140
|
+
{
|
|
141
|
+
"request": {
|
|
142
|
+
"method": "GET",
|
|
143
|
+
"url": "https://api.example.com/users",
|
|
144
|
+
"headers": {
|
|
145
|
+
"host": "api.example.com",
|
|
146
|
+
"accept": "*/*",
|
|
147
|
+
"accept-encoding": "gzip, deflate",
|
|
148
|
+
"connection": "keep-alive",
|
|
149
|
+
"user-agent": "python-httpx/0.28.1"
|
|
150
|
+
},
|
|
151
|
+
"body": ""
|
|
152
|
+
},
|
|
153
|
+
"response": {
|
|
154
|
+
"status_code": 200,
|
|
155
|
+
"headers": {
|
|
156
|
+
"date": "Thu, 21 Aug 2025 15:49:45 GMT",
|
|
157
|
+
"content-type": "application/json; charset=utf-8",
|
|
158
|
+
"connection": "keep-alive",
|
|
159
|
+
"server": "nginx/1.18.0"
|
|
160
|
+
},
|
|
161
|
+
"body": "{\n \"users\": [\n {\n \"id\": 1,\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\"\n }\n ]\n}"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Content Encoding
|
|
168
|
+
|
|
169
|
+
The plugin intelligently handles different content types:
|
|
170
|
+
|
|
171
|
+
- **JSON**: Formatted with proper indentation for readability
|
|
172
|
+
- **Text**: Stored as UTF-8 strings
|
|
173
|
+
- **Binary**: Base64 encoded
|
|
174
|
+
|
|
175
|
+
## Advanced Examples
|
|
176
|
+
|
|
177
|
+
### Testing API with Multiple Requests
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
@pytest.mark.anyio
|
|
181
|
+
@pytest.mark.parametrize(
|
|
182
|
+
"http_snapshot",
|
|
183
|
+
[inline_snapshot.external("uuid:multi-request-test.json")],
|
|
184
|
+
)
|
|
185
|
+
async def test_multiple_requests(snapshot_httpx_client: httpx.AsyncClient) -> None:
|
|
186
|
+
# Create a user
|
|
187
|
+
create_response = await snapshot_httpx_client.post(
|
|
188
|
+
"https://api.example.com/users",
|
|
189
|
+
json={"name": "Alice", "email": "alice@example.com"}
|
|
190
|
+
)
|
|
191
|
+
assert create_response.status_code == 201
|
|
192
|
+
user_id = create_response.json()["id"]
|
|
193
|
+
|
|
194
|
+
# Fetch the user
|
|
195
|
+
get_response = await snapshot_httpx_client.get(
|
|
196
|
+
f"https://api.example.com/users/{user_id}"
|
|
197
|
+
)
|
|
198
|
+
assert get_response.status_code == 200
|
|
199
|
+
assert get_response.json()["name"] == "Alice"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Testing with Authentication
|
|
203
|
+
|
|
204
|
+
```python
|
|
205
|
+
@pytest.mark.parametrize(
|
|
206
|
+
"http_snapshot, http_snapshot_serializer_options",
|
|
207
|
+
[
|
|
208
|
+
(
|
|
209
|
+
inline_snapshot.external("uuid:auth-test.json"),
|
|
210
|
+
SnapshotSerializerOptions(exclude_request_headers=["Authorization"]),
|
|
211
|
+
),
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
def test_authenticated_request(
|
|
215
|
+
snapshot_requests_session: requests.Session,
|
|
216
|
+
http_snapshot_serializer_options,
|
|
217
|
+
) -> None:
|
|
218
|
+
# The Authorization header will be excluded from the snapshot
|
|
219
|
+
response = snapshot_requests_session.get(
|
|
220
|
+
"https://api.example.com/profile",
|
|
221
|
+
headers={"Authorization": "Bearer secret-token"}
|
|
222
|
+
)
|
|
223
|
+
assert response.status_code == 200
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Best Practices
|
|
227
|
+
|
|
228
|
+
1. **Exclude sensitive data**: Always exclude headers containing secrets, tokens, or personal data
|
|
229
|
+
2. **Review snapshots**: Check generated snapshot files into version control and review changes
|
|
230
|
+
3. **Use live mode sparingly**: Only run in live mode when you need to update snapshots
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "http-snapshot"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "http-snapshot is a pytest plugin that snapshots requests made with popular Python HTTP clients."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
[dependency-groups]
|
|
10
|
+
dev = [
|
|
11
|
+
"anyio>=4.10.0",
|
|
12
|
+
"httpx>=0.28.1",
|
|
13
|
+
"inline-snapshot>=0.27.2",
|
|
14
|
+
"mypy>=1.17.1",
|
|
15
|
+
"pytest>=8.4.1",
|
|
16
|
+
"ruff>=0.12.10",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.mypy]
|
|
20
|
+
strict = true
|
|
21
|
+
|
|
22
|
+
[project.entry-points.pytest11]
|
|
23
|
+
http_snapshot = "http_snapshot._pytest_plugin"
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
httpx = ["httpx>=0.28.1"]
|
|
27
|
+
requests = ["requests>=2.32.5"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
pythonpath = ["src"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from typing import Any, assert_never, overload
|
|
2
|
+
import httpx
|
|
3
|
+
from inline_snapshot import Snapshot
|
|
4
|
+
|
|
5
|
+
from .._models import Request, Response
|
|
6
|
+
from .._serializer import snapshot_to_internal
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@overload
|
|
10
|
+
def httpx_to_internal(
|
|
11
|
+
model: httpx.Request,
|
|
12
|
+
) -> Request: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@overload
|
|
16
|
+
def httpx_to_internal(
|
|
17
|
+
model: httpx.Response,
|
|
18
|
+
) -> Response: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def httpx_to_internal(
|
|
22
|
+
model: httpx.Request | httpx.Response,
|
|
23
|
+
) -> Request | Response:
|
|
24
|
+
if isinstance(model, httpx.Request):
|
|
25
|
+
return Request(
|
|
26
|
+
method=model.method,
|
|
27
|
+
url=str(model.url),
|
|
28
|
+
headers=dict(model.headers),
|
|
29
|
+
body=model.content,
|
|
30
|
+
)
|
|
31
|
+
elif isinstance(model, httpx.Response):
|
|
32
|
+
model.aiter_bytes
|
|
33
|
+
return Response(
|
|
34
|
+
status_code=model.status_code,
|
|
35
|
+
headers=dict(model.headers),
|
|
36
|
+
body=model.content,
|
|
37
|
+
)
|
|
38
|
+
else:
|
|
39
|
+
assert_never(model, "Unsupported model type for serialization")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def internal_to_httpx(model: Response) -> httpx.Response:
|
|
43
|
+
return httpx.Response(
|
|
44
|
+
status_code=model.status_code,
|
|
45
|
+
headers=model.headers,
|
|
46
|
+
content=model.body,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SnapshotTransport(httpx.AsyncBaseTransport):
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
next_transport: httpx.AsyncBaseTransport,
|
|
54
|
+
snapshot: Snapshot[list[dict[str, Any]]],
|
|
55
|
+
is_live: bool,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.is_live = is_live
|
|
58
|
+
self.next_transport = next_transport
|
|
59
|
+
self.collected_pairs: list[tuple[Request, Response]] = []
|
|
60
|
+
self.snapshot = snapshot
|
|
61
|
+
self._request_number = -1
|
|
62
|
+
|
|
63
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
64
|
+
self._request_number += 1
|
|
65
|
+
|
|
66
|
+
if self.is_live:
|
|
67
|
+
# In live mode, we would normally send the request to the server.
|
|
68
|
+
response = await self.next_transport.handle_async_request(request)
|
|
69
|
+
await response.aread()
|
|
70
|
+
self.collected_pairs.append(
|
|
71
|
+
(httpx_to_internal(request), httpx_to_internal(response))
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
internal = snapshot_to_internal(self.snapshot)
|
|
75
|
+
response = internal_to_httpx(internal[self._request_number])
|
|
76
|
+
return response
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from typing import Any, Mapping, assert_never, overload
|
|
2
|
+
from inline_snapshot import Snapshot
|
|
3
|
+
from requests.adapters import HTTPAdapter
|
|
4
|
+
from requests.models import PreparedRequest, Response
|
|
5
|
+
from http_snapshot._models import Request, Response as InternalResponse
|
|
6
|
+
from http_snapshot._serializer import snapshot_to_internal
|
|
7
|
+
from urllib3.util.retry import Retry as Retry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@overload
|
|
11
|
+
def requests_to_internal(
|
|
12
|
+
model: PreparedRequest,
|
|
13
|
+
) -> Request: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@overload
|
|
17
|
+
def requests_to_internal(
|
|
18
|
+
model: Response,
|
|
19
|
+
) -> InternalResponse: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def requests_to_internal(
|
|
23
|
+
model: PreparedRequest | Response,
|
|
24
|
+
) -> Request | InternalResponse:
|
|
25
|
+
if isinstance(model, PreparedRequest):
|
|
26
|
+
body: bytes
|
|
27
|
+
if isinstance(model.body, str):
|
|
28
|
+
body = model.body.encode("utf-8")
|
|
29
|
+
elif isinstance(model.body, bytes):
|
|
30
|
+
body = body
|
|
31
|
+
else:
|
|
32
|
+
body = b""
|
|
33
|
+
return Request(
|
|
34
|
+
method=model.method,
|
|
35
|
+
url=str(model.url),
|
|
36
|
+
headers=dict(model.headers),
|
|
37
|
+
body=body,
|
|
38
|
+
)
|
|
39
|
+
elif isinstance(model, Response):
|
|
40
|
+
content = model.content
|
|
41
|
+
if not isinstance(content, bytes):
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
f"Expected response content to be bytes, got {type(content).__name__}"
|
|
44
|
+
)
|
|
45
|
+
return InternalResponse(
|
|
46
|
+
status_code=model.status_code,
|
|
47
|
+
headers=dict(model.headers),
|
|
48
|
+
body=content,
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
assert_never(model, "Unsupported model type for serialization")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def internal_to_requests(model: InternalResponse, adapter: HTTPAdapter) -> Response:
|
|
55
|
+
response = Response()
|
|
56
|
+
|
|
57
|
+
response.status_code = model.status_code
|
|
58
|
+
for key, value in model.headers.items():
|
|
59
|
+
response.headers[key] = value
|
|
60
|
+
response._content = model.body
|
|
61
|
+
return response
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SnapshotAdapter(HTTPAdapter):
|
|
65
|
+
"""
|
|
66
|
+
A custom HTTPAdapter that can be used with requests to capture HTTP interactions
|
|
67
|
+
for snapshot testing.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
snapshot: Snapshot[list[dict[str, Any]]],
|
|
73
|
+
pool_connections: int = 10,
|
|
74
|
+
pool_maxsize: int = 10,
|
|
75
|
+
max_retries: Retry | int | None = 0,
|
|
76
|
+
pool_block: bool = False,
|
|
77
|
+
is_live: bool = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
super().__init__(pool_connections, pool_maxsize, max_retries, pool_block)
|
|
80
|
+
self.snapshot = snapshot
|
|
81
|
+
self.is_live = is_live
|
|
82
|
+
self.collected_pairs: list[tuple[PreparedRequest, Response]] = []
|
|
83
|
+
self._request_number = -1
|
|
84
|
+
|
|
85
|
+
def send(
|
|
86
|
+
self,
|
|
87
|
+
request: PreparedRequest,
|
|
88
|
+
stream: bool = False,
|
|
89
|
+
timeout: None | float | tuple[float, float] | tuple[float, None] = None,
|
|
90
|
+
verify: bool | str = True,
|
|
91
|
+
cert: None | bytes | str | tuple[bytes | str, bytes | str] = None,
|
|
92
|
+
proxies: Mapping[str, str] | None = None,
|
|
93
|
+
) -> Response:
|
|
94
|
+
self._request_number += 1
|
|
95
|
+
|
|
96
|
+
if self.is_live:
|
|
97
|
+
response = super().send(request, False, timeout, verify, cert, proxies)
|
|
98
|
+
self.collected_pairs.append(
|
|
99
|
+
(requests_to_internal(request), requests_to_internal(response))
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
internal = snapshot_to_internal(self.snapshot)
|
|
103
|
+
response = internal_to_requests(internal[self._request_number], self)
|
|
104
|
+
|
|
105
|
+
return response
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Mapping
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Request:
|
|
7
|
+
method: str
|
|
8
|
+
url: str
|
|
9
|
+
headers: Mapping[str, str]
|
|
10
|
+
body: bytes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Response:
|
|
15
|
+
status_code: int
|
|
16
|
+
headers: Mapping[str, str]
|
|
17
|
+
body: bytes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, Iterator
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
import inline_snapshot
|
|
6
|
+
|
|
7
|
+
from ._serializer import SnapshotSerializerOptions, internal_to_snapshot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import httpx
|
|
12
|
+
except ImportError:
|
|
13
|
+
httpx: Any = None
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import requests
|
|
17
|
+
except ImportError:
|
|
18
|
+
requests: Any = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_live() -> bool:
|
|
22
|
+
return os.getenv("HTTP_SNAPSHOT_LIVE") == "1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def http_snapshot_serializer_options() -> SnapshotSerializerOptions:
|
|
27
|
+
return SnapshotSerializerOptions()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def snapshot_httpx_client(
|
|
32
|
+
http_snapshot: inline_snapshot.Snapshot[Any],
|
|
33
|
+
http_snapshot_serializer_options: SnapshotSerializerOptions,
|
|
34
|
+
) -> Iterator[httpx.AsyncClient]:
|
|
35
|
+
if httpx is None:
|
|
36
|
+
raise ImportError(
|
|
37
|
+
"httpx is not installed. Please install http-snapshot with httpx feature [pip install http-snapshot[httpx]]"
|
|
38
|
+
)
|
|
39
|
+
from ._integrations._httpx import SnapshotTransport
|
|
40
|
+
|
|
41
|
+
snapshot_transport = SnapshotTransport(
|
|
42
|
+
httpx.AsyncHTTPTransport(),
|
|
43
|
+
http_snapshot,
|
|
44
|
+
is_live=is_live(),
|
|
45
|
+
)
|
|
46
|
+
yield httpx.AsyncClient(
|
|
47
|
+
transport=snapshot_transport,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if snapshot_transport.is_live:
|
|
51
|
+
assert (
|
|
52
|
+
internal_to_snapshot(
|
|
53
|
+
snapshot_transport.collected_pairs, http_snapshot_serializer_options
|
|
54
|
+
)
|
|
55
|
+
== snapshot_transport.snapshot
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def snapshot_requests_session(
|
|
61
|
+
http_snapshot: inline_snapshot.Snapshot[Any],
|
|
62
|
+
http_snapshot_serializer_options: SnapshotSerializerOptions,
|
|
63
|
+
) -> Iterator[requests.Session]:
|
|
64
|
+
if requests is None:
|
|
65
|
+
raise ImportError(
|
|
66
|
+
"requests is not installed. Please install http-snapshot with requests feature [pip install http-snapshot[requests]]"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
from ._integrations._requests import SnapshotAdapter
|
|
70
|
+
|
|
71
|
+
with requests.Session() as session:
|
|
72
|
+
adapter = SnapshotAdapter(snapshot=http_snapshot, is_live=is_live())
|
|
73
|
+
session.mount("http://", adapter)
|
|
74
|
+
session.mount("https://", adapter)
|
|
75
|
+
|
|
76
|
+
yield session
|
|
77
|
+
|
|
78
|
+
if adapter.is_live:
|
|
79
|
+
assert (
|
|
80
|
+
internal_to_snapshot(
|
|
81
|
+
adapter.collected_pairs, http_snapshot_serializer_options
|
|
82
|
+
)
|
|
83
|
+
== adapter.snapshot
|
|
84
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any, Iterable, Mapping, Optional
|
|
4
|
+
import inline_snapshot
|
|
5
|
+
import pytest
|
|
6
|
+
import base64
|
|
7
|
+
|
|
8
|
+
from ._models import Request, Response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class SnapshotSerializerOptions:
|
|
13
|
+
include_request: bool = True
|
|
14
|
+
exclude_request_headers: Iterable[str] = field(default_factory=list)
|
|
15
|
+
exclude_response_headers: Iterable[str] = field(default_factory=list)
|
|
16
|
+
|
|
17
|
+
def __post_init__(self) -> None:
|
|
18
|
+
self.exclude_request_headers = set(
|
|
19
|
+
header.lower() for header in self.exclude_request_headers
|
|
20
|
+
)
|
|
21
|
+
self.exclude_response_headers = set(
|
|
22
|
+
header.lower() for header in self.exclude_response_headers
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_snapshot_value(snapshot: Any) -> Any:
|
|
27
|
+
# todo fix this
|
|
28
|
+
return snapshot._load_value()
|
|
29
|
+
if not hasattr(snapshot, "_old_value"):
|
|
30
|
+
return snapshot
|
|
31
|
+
|
|
32
|
+
old = snapshot._old_value
|
|
33
|
+
if not hasattr(old, "value"):
|
|
34
|
+
return old
|
|
35
|
+
|
|
36
|
+
loader = getattr(old.value, "_load_value", None)
|
|
37
|
+
return loader() if loader else old.value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def encode_content(content: bytes, content_type: str) -> str:
|
|
41
|
+
if content_type == "application/json":
|
|
42
|
+
return json.dumps(json.loads(content), indent=2, ensure_ascii=False)
|
|
43
|
+
elif content_type.startswith("text/"):
|
|
44
|
+
return content.decode("utf-8")
|
|
45
|
+
else:
|
|
46
|
+
# base64 any other binary content
|
|
47
|
+
return base64.b64encode(content).decode("utf-8")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def decode_content(encoded_content: str, content_type: str) -> bytes:
|
|
51
|
+
if content_type == "application/json":
|
|
52
|
+
return json.dumps(
|
|
53
|
+
json.loads(encoded_content), indent=2, ensure_ascii=False
|
|
54
|
+
).encode("utf-8")
|
|
55
|
+
elif content_type.startswith("text/"):
|
|
56
|
+
return encoded_content.encode("utf-8")
|
|
57
|
+
else:
|
|
58
|
+
# decode base64 for other binary content
|
|
59
|
+
return base64.b64decode(encoded_content)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def exclude_sensitive_request_headers(
|
|
63
|
+
headers: Mapping[str, str], options: Optional[SnapshotSerializerOptions] = None
|
|
64
|
+
) -> dict[str, str]:
|
|
65
|
+
options = options or SnapshotSerializerOptions()
|
|
66
|
+
return {
|
|
67
|
+
k: v
|
|
68
|
+
for k, v in headers.items()
|
|
69
|
+
if k.lower() not in options.exclude_request_headers
|
|
70
|
+
and k.lower() not in ("authorization", "cookie")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def exclude_sensitive_response_headers(
|
|
75
|
+
headers: Mapping[str, str],
|
|
76
|
+
options: Optional[SnapshotSerializerOptions] = None,
|
|
77
|
+
) -> dict[str, str]:
|
|
78
|
+
options = options or SnapshotSerializerOptions()
|
|
79
|
+
return {
|
|
80
|
+
k: v
|
|
81
|
+
for k, v in headers.items()
|
|
82
|
+
if k.lower() not in options.exclude_response_headers
|
|
83
|
+
and k.lower()
|
|
84
|
+
not in (
|
|
85
|
+
"set-cookie",
|
|
86
|
+
"www-authenticate",
|
|
87
|
+
"proxy-authenticate",
|
|
88
|
+
"authentication-info",
|
|
89
|
+
"proxy-authentication-info",
|
|
90
|
+
"transfer-encoding",
|
|
91
|
+
"content-encoding",
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def internal_to_snapshot(
|
|
97
|
+
pairs: list[tuple[Request, Response]],
|
|
98
|
+
options: Optional[SnapshotSerializerOptions] = None,
|
|
99
|
+
) -> list[dict[str, Any]]:
|
|
100
|
+
options = options or SnapshotSerializerOptions()
|
|
101
|
+
to_compare = []
|
|
102
|
+
|
|
103
|
+
for request, response in pairs:
|
|
104
|
+
repr: dict[str, Any] = {}
|
|
105
|
+
|
|
106
|
+
if options.include_request:
|
|
107
|
+
repr["request"] = {
|
|
108
|
+
"method": request.method,
|
|
109
|
+
"url": str(request.url),
|
|
110
|
+
"headers": exclude_sensitive_request_headers(
|
|
111
|
+
dict(request.headers), options
|
|
112
|
+
),
|
|
113
|
+
"body": encode_content(
|
|
114
|
+
request.body, request.headers.get("Content-Type", "")
|
|
115
|
+
),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
repr["response"] = {
|
|
119
|
+
"status_code": response.status_code,
|
|
120
|
+
"headers": exclude_sensitive_response_headers(
|
|
121
|
+
dict(response.headers), options
|
|
122
|
+
),
|
|
123
|
+
"body": encode_content(
|
|
124
|
+
response.body, response.headers.get("Content-Type", "")
|
|
125
|
+
),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
to_compare.append(repr)
|
|
129
|
+
return to_compare
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def snapshot_to_internal(
|
|
133
|
+
snapshot: inline_snapshot.Snapshot[list[dict[str, Any]]],
|
|
134
|
+
) -> list[Response]:
|
|
135
|
+
responses = []
|
|
136
|
+
|
|
137
|
+
value: list[dict[str, Any]] = get_snapshot_value(snapshot)
|
|
138
|
+
for item in value:
|
|
139
|
+
response = Response(
|
|
140
|
+
status_code=item["response"]["status_code"],
|
|
141
|
+
headers=item["response"]["headers"],
|
|
142
|
+
body=decode_content(
|
|
143
|
+
item["response"]["body"],
|
|
144
|
+
item["response"]["headers"].get("Content-Type", ""),
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
responses.append(response)
|
|
148
|
+
|
|
149
|
+
return responses
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.fixture
|
|
153
|
+
def snapshot() -> Any:
|
|
154
|
+
return inline_snapshot.external("uuid:93ec4e8a-8760-4cd1-8330-df818d448e0d.json")
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: http-snapshot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: http-snapshot is a pytest plugin that snapshots requests made with popular Python HTTP clients.
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: httpx
|
|
8
|
+
Requires-Dist: httpx>=0.28.1; extra == "httpx"
|
|
9
|
+
Provides-Extra: requests
|
|
10
|
+
Requires-Dist: requests>=2.32.5; extra == "requests"
|
|
11
|
+
|
|
12
|
+
# http-snapshot
|
|
13
|
+
|
|
14
|
+
`http-snapshot` is a pytest plugin that captures and snapshots HTTP requests/responses made with popular Python HTTP clients like `httpx` and `requests`. It uses [inline-snapshot](https://github.com/15r10nk/inline-snapshot) to store HTTP interactions as JSON files, enabling fast and reliable HTTP testing without making actual network calls.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- ๐ **Support for multiple HTTP clients**: `httpx` (async) and `requests` (sync)
|
|
19
|
+
- ๐ธ **Automatic HTTP interaction capture**: Records both requests and responses
|
|
20
|
+
- ๐ **Security-aware**: Automatically excludes sensitive headers like authorization and cookies
|
|
21
|
+
- โ๏ธ **Configurable**: Control what gets captured and what gets excluded
|
|
22
|
+
- ๐งช **pytest integration**: Works seamlessly with your existing pytest test suite
|
|
23
|
+
- ๐ **External snapshots**: Stores snapshots in organized JSON files
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install http-snapshot
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For specific HTTP client support:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# For httpx support
|
|
35
|
+
pip install http-snapshot[httpx]
|
|
36
|
+
|
|
37
|
+
# For requests support
|
|
38
|
+
pip install http-snapshot[requests]
|
|
39
|
+
|
|
40
|
+
# For both
|
|
41
|
+
pip install http-snapshot[httpx,requests]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
### Using with httpx (async)
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import httpx
|
|
50
|
+
import pytest
|
|
51
|
+
import inline_snapshot
|
|
52
|
+
|
|
53
|
+
@pytest.mark.anyio
|
|
54
|
+
@pytest.mark.parametrize(
|
|
55
|
+
"http_snapshot",
|
|
56
|
+
[inline_snapshot.external("uuid:my-test-snapshot.json")],
|
|
57
|
+
)
|
|
58
|
+
async def test_api_call(snapshot_httpx_client: httpx.AsyncClient) -> None:
|
|
59
|
+
# This will be captured on first run, replayed on subsequent runs
|
|
60
|
+
response = await snapshot_httpx_client.get("https://api.example.com/users")
|
|
61
|
+
assert response.status_code == 200
|
|
62
|
+
assert "users" in response.json()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Using with requests (sync)
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import requests
|
|
69
|
+
import pytest
|
|
70
|
+
import inline_snapshot
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize(
|
|
73
|
+
"http_snapshot",
|
|
74
|
+
[inline_snapshot.external("uuid:my-test-snapshot.json")],
|
|
75
|
+
)
|
|
76
|
+
def test_api_call(snapshot_requests_session: requests.Session) -> None:
|
|
77
|
+
# This will be captured on first run, replayed on subsequent runs
|
|
78
|
+
response = snapshot_requests_session.get("https://api.example.com/users")
|
|
79
|
+
assert response.status_code == 200
|
|
80
|
+
assert "users" in response.json()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## How It Works
|
|
84
|
+
|
|
85
|
+
### Live Mode vs Replay Mode
|
|
86
|
+
|
|
87
|
+
The plugin operates in two modes:
|
|
88
|
+
|
|
89
|
+
1. **Live Mode**: When `HTTP_SNAPSHOT_LIVE=1` is set, actual HTTP requests are made and responses are captured
|
|
90
|
+
2. **Replay Mode**: When not in live mode, previously captured responses are replayed
|
|
91
|
+
|
|
92
|
+
### Running in Live Mode
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Capture new snapshots
|
|
96
|
+
HTTP_SNAPSHOT_LIVE=1 pytest tests/
|
|
97
|
+
|
|
98
|
+
# Replay existing snapshots (default)
|
|
99
|
+
pytest tests/
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Configuration Options
|
|
103
|
+
|
|
104
|
+
You can customize what gets captured using `SnapshotSerializerOptions`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
import pytest
|
|
108
|
+
import inline_snapshot
|
|
109
|
+
from http_snapshot._serializer import SnapshotSerializerOptions
|
|
110
|
+
|
|
111
|
+
@pytest.mark.parametrize(
|
|
112
|
+
"http_snapshot, http_snapshot_serializer_options",
|
|
113
|
+
[
|
|
114
|
+
(
|
|
115
|
+
inline_snapshot.external("uuid:my-test-snapshot.json"),
|
|
116
|
+
SnapshotSerializerOptions(
|
|
117
|
+
exclude_request_headers=["X-API-Key"],
|
|
118
|
+
include_request=True, # Include request details in snapshot
|
|
119
|
+
),
|
|
120
|
+
),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
def test_with_custom_options(
|
|
124
|
+
snapshot_requests_session: requests.Session,
|
|
125
|
+
http_snapshot_serializer_options: SnapshotSerializerOptions,
|
|
126
|
+
) -> None:
|
|
127
|
+
response = snapshot_requests_session.get(
|
|
128
|
+
"https://api.example.com/protected",
|
|
129
|
+
headers={"X-API-Key": "secret-key"}
|
|
130
|
+
)
|
|
131
|
+
assert response.status_code == 200
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Available Options
|
|
135
|
+
|
|
136
|
+
- `include_request`: Whether to include request details in snapshots (default: `True`)
|
|
137
|
+
- `exclude_request_headers`: List of request headers to exclude from snapshots
|
|
138
|
+
- `exclude_response_headers`: List of response headers to exclude from snapshots
|
|
139
|
+
|
|
140
|
+
By default, the following sensitive headers are always excluded:
|
|
141
|
+
|
|
142
|
+
- **Request**: `authorization`, `cookie`
|
|
143
|
+
- **Response**: `set-cookie`, `www-authenticate`, `proxy-authenticate`, `authentication-info`, `proxy-authentication-info`, `transfer-encoding`, `content-encoding`
|
|
144
|
+
|
|
145
|
+
## Snapshot Format
|
|
146
|
+
|
|
147
|
+
Snapshots are stored as JSON files with the following structure:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
[
|
|
151
|
+
{
|
|
152
|
+
"request": {
|
|
153
|
+
"method": "GET",
|
|
154
|
+
"url": "https://api.example.com/users",
|
|
155
|
+
"headers": {
|
|
156
|
+
"host": "api.example.com",
|
|
157
|
+
"accept": "*/*",
|
|
158
|
+
"accept-encoding": "gzip, deflate",
|
|
159
|
+
"connection": "keep-alive",
|
|
160
|
+
"user-agent": "python-httpx/0.28.1"
|
|
161
|
+
},
|
|
162
|
+
"body": ""
|
|
163
|
+
},
|
|
164
|
+
"response": {
|
|
165
|
+
"status_code": 200,
|
|
166
|
+
"headers": {
|
|
167
|
+
"date": "Thu, 21 Aug 2025 15:49:45 GMT",
|
|
168
|
+
"content-type": "application/json; charset=utf-8",
|
|
169
|
+
"connection": "keep-alive",
|
|
170
|
+
"server": "nginx/1.18.0"
|
|
171
|
+
},
|
|
172
|
+
"body": "{\n \"users\": [\n {\n \"id\": 1,\n \"name\": \"John Doe\",\n \"email\": \"john@example.com\"\n }\n ]\n}"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Content Encoding
|
|
179
|
+
|
|
180
|
+
The plugin intelligently handles different content types:
|
|
181
|
+
|
|
182
|
+
- **JSON**: Formatted with proper indentation for readability
|
|
183
|
+
- **Text**: Stored as UTF-8 strings
|
|
184
|
+
- **Binary**: Base64 encoded
|
|
185
|
+
|
|
186
|
+
## Advanced Examples
|
|
187
|
+
|
|
188
|
+
### Testing API with Multiple Requests
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
@pytest.mark.anyio
|
|
192
|
+
@pytest.mark.parametrize(
|
|
193
|
+
"http_snapshot",
|
|
194
|
+
[inline_snapshot.external("uuid:multi-request-test.json")],
|
|
195
|
+
)
|
|
196
|
+
async def test_multiple_requests(snapshot_httpx_client: httpx.AsyncClient) -> None:
|
|
197
|
+
# Create a user
|
|
198
|
+
create_response = await snapshot_httpx_client.post(
|
|
199
|
+
"https://api.example.com/users",
|
|
200
|
+
json={"name": "Alice", "email": "alice@example.com"}
|
|
201
|
+
)
|
|
202
|
+
assert create_response.status_code == 201
|
|
203
|
+
user_id = create_response.json()["id"]
|
|
204
|
+
|
|
205
|
+
# Fetch the user
|
|
206
|
+
get_response = await snapshot_httpx_client.get(
|
|
207
|
+
f"https://api.example.com/users/{user_id}"
|
|
208
|
+
)
|
|
209
|
+
assert get_response.status_code == 200
|
|
210
|
+
assert get_response.json()["name"] == "Alice"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Testing with Authentication
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
@pytest.mark.parametrize(
|
|
217
|
+
"http_snapshot, http_snapshot_serializer_options",
|
|
218
|
+
[
|
|
219
|
+
(
|
|
220
|
+
inline_snapshot.external("uuid:auth-test.json"),
|
|
221
|
+
SnapshotSerializerOptions(exclude_request_headers=["Authorization"]),
|
|
222
|
+
),
|
|
223
|
+
],
|
|
224
|
+
)
|
|
225
|
+
def test_authenticated_request(
|
|
226
|
+
snapshot_requests_session: requests.Session,
|
|
227
|
+
http_snapshot_serializer_options,
|
|
228
|
+
) -> None:
|
|
229
|
+
# The Authorization header will be excluded from the snapshot
|
|
230
|
+
response = snapshot_requests_session.get(
|
|
231
|
+
"https://api.example.com/profile",
|
|
232
|
+
headers={"Authorization": "Bearer secret-token"}
|
|
233
|
+
)
|
|
234
|
+
assert response.status_code == 200
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Best Practices
|
|
238
|
+
|
|
239
|
+
1. **Exclude sensitive data**: Always exclude headers containing secrets, tokens, or personal data
|
|
240
|
+
2. **Review snapshots**: Check generated snapshot files into version control and review changes
|
|
241
|
+
3. **Use live mode sparingly**: Only run in live mode when you need to update snapshots
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/http_snapshot/__init__.py
|
|
4
|
+
src/http_snapshot/_models.py
|
|
5
|
+
src/http_snapshot/_pytest_plugin.py
|
|
6
|
+
src/http_snapshot/_serializer.py
|
|
7
|
+
src/http_snapshot.egg-info/PKG-INFO
|
|
8
|
+
src/http_snapshot.egg-info/SOURCES.txt
|
|
9
|
+
src/http_snapshot.egg-info/dependency_links.txt
|
|
10
|
+
src/http_snapshot.egg-info/entry_points.txt
|
|
11
|
+
src/http_snapshot.egg-info/requires.txt
|
|
12
|
+
src/http_snapshot.egg-info/top_level.txt
|
|
13
|
+
src/http_snapshot/_integrations/__init__.py
|
|
14
|
+
src/http_snapshot/_integrations/_httpx.py
|
|
15
|
+
src/http_snapshot/_integrations/_requests.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
http_snapshot
|