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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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,2 @@
1
+ [pytest11]
2
+ http_snapshot = http_snapshot._pytest_plugin
@@ -0,0 +1,6 @@
1
+
2
+ [httpx]
3
+ httpx>=0.28.1
4
+
5
+ [requests]
6
+ requests>=2.32.5
@@ -0,0 +1 @@
1
+ http_snapshot