fixturify 0.1.9__py3-none-any.whl
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.
- fixturify/__init__.py +21 -0
- fixturify/_utils/__init__.py +7 -0
- fixturify/_utils/_constants.py +10 -0
- fixturify/_utils/_fixture_discovery.py +165 -0
- fixturify/_utils/_path_resolver.py +135 -0
- fixturify/http_d/__init__.py +80 -0
- fixturify/http_d/_config.py +214 -0
- fixturify/http_d/_decorator.py +267 -0
- fixturify/http_d/_exceptions.py +153 -0
- fixturify/http_d/_fixture_discovery.py +33 -0
- fixturify/http_d/_matcher.py +372 -0
- fixturify/http_d/_mock_context.py +154 -0
- fixturify/http_d/_models.py +205 -0
- fixturify/http_d/_patcher.py +524 -0
- fixturify/http_d/_player.py +222 -0
- fixturify/http_d/_recorder.py +1350 -0
- fixturify/http_d/_stubs/__init__.py +8 -0
- fixturify/http_d/_stubs/_aiohttp.py +220 -0
- fixturify/http_d/_stubs/_connection.py +478 -0
- fixturify/http_d/_stubs/_httpcore.py +269 -0
- fixturify/http_d/_stubs/_tornado.py +95 -0
- fixturify/http_d/_utils.py +194 -0
- fixturify/json_assert/__init__.py +13 -0
- fixturify/json_assert/_actual_saver.py +67 -0
- fixturify/json_assert/_assert.py +173 -0
- fixturify/json_assert/_comparator.py +183 -0
- fixturify/json_assert/_diff_formatter.py +265 -0
- fixturify/json_assert/_normalizer.py +83 -0
- fixturify/object_mapper/__init__.py +5 -0
- fixturify/object_mapper/_deserializers/__init__.py +19 -0
- fixturify/object_mapper/_deserializers/_base.py +186 -0
- fixturify/object_mapper/_deserializers/_dataclass.py +52 -0
- fixturify/object_mapper/_deserializers/_plain.py +55 -0
- fixturify/object_mapper/_deserializers/_pydantic_v1.py +38 -0
- fixturify/object_mapper/_deserializers/_pydantic_v2.py +41 -0
- fixturify/object_mapper/_deserializers/_sqlalchemy.py +72 -0
- fixturify/object_mapper/_deserializers/_sqlmodel.py +43 -0
- fixturify/object_mapper/_detectors/__init__.py +5 -0
- fixturify/object_mapper/_detectors/_type_detector.py +186 -0
- fixturify/object_mapper/_serializers/__init__.py +19 -0
- fixturify/object_mapper/_serializers/_base.py +260 -0
- fixturify/object_mapper/_serializers/_dataclass.py +55 -0
- fixturify/object_mapper/_serializers/_plain.py +49 -0
- fixturify/object_mapper/_serializers/_pydantic_v1.py +49 -0
- fixturify/object_mapper/_serializers/_pydantic_v2.py +49 -0
- fixturify/object_mapper/_serializers/_sqlalchemy.py +70 -0
- fixturify/object_mapper/_serializers/_sqlmodel.py +54 -0
- fixturify/object_mapper/mapper.py +256 -0
- fixturify/read_d/__init__.py +5 -0
- fixturify/read_d/_decorator.py +193 -0
- fixturify/read_d/_fixture_loader.py +88 -0
- fixturify/sql_d/__init__.py +7 -0
- fixturify/sql_d/_config.py +30 -0
- fixturify/sql_d/_decorator.py +373 -0
- fixturify/sql_d/_driver_registry.py +133 -0
- fixturify/sql_d/_executor.py +82 -0
- fixturify/sql_d/_fixture_discovery.py +55 -0
- fixturify/sql_d/_phase.py +10 -0
- fixturify/sql_d/_strategies/__init__.py +11 -0
- fixturify/sql_d/_strategies/_aiomysql.py +63 -0
- fixturify/sql_d/_strategies/_aiosqlite.py +29 -0
- fixturify/sql_d/_strategies/_asyncpg.py +34 -0
- fixturify/sql_d/_strategies/_base.py +118 -0
- fixturify/sql_d/_strategies/_mysql.py +70 -0
- fixturify/sql_d/_strategies/_psycopg.py +35 -0
- fixturify/sql_d/_strategies/_psycopg2.py +40 -0
- fixturify/sql_d/_strategies/_registry.py +109 -0
- fixturify/sql_d/_strategies/_sqlite.py +33 -0
- fixturify-0.1.9.dist-info/METADATA +122 -0
- fixturify-0.1.9.dist-info/RECORD +71 -0
- fixturify-0.1.9.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Playback logic for HTTP mock responses."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, List, Optional, Set, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from fixturify._utils import ENCODING
|
|
8
|
+
|
|
9
|
+
from fixturify.http_d._exceptions import NoMatchingRecordingError, UnusedRecordingsError
|
|
10
|
+
from fixturify.http_d._matcher import RequestMatcher
|
|
11
|
+
from fixturify.http_d._models import HttpMapping, HttpRequest, HttpResponse
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fixturify.http_d._config import HttpTestConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Directory name for binary response files (WireMock convention)
|
|
18
|
+
FILES_DIR = "__files"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _deserialize_body(body: Any) -> Optional[str]:
|
|
22
|
+
"""
|
|
23
|
+
Deserialize body from JSON storage format to string.
|
|
24
|
+
|
|
25
|
+
If body is a dict/list (native JSON), serialize it back to string.
|
|
26
|
+
If body is already a string, return as-is.
|
|
27
|
+
If body is None, return None.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
body: Body value from JSON file (could be dict, list, string, or None)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Body as string (or None)
|
|
34
|
+
"""
|
|
35
|
+
if body is None:
|
|
36
|
+
return None
|
|
37
|
+
if isinstance(body, str):
|
|
38
|
+
return body
|
|
39
|
+
# For dict/list, serialize back to JSON string
|
|
40
|
+
return json.dumps(body, ensure_ascii=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HttpPlayer:
|
|
44
|
+
"""
|
|
45
|
+
Plays back recorded HTTP responses during tests.
|
|
46
|
+
|
|
47
|
+
Matches incoming requests against recorded request/response pairs
|
|
48
|
+
and returns the appropriate recorded response. Supports loading
|
|
49
|
+
binary response bodies from external files.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
file_path: Path,
|
|
55
|
+
config: Optional["HttpTestConfig"] = None,
|
|
56
|
+
ignore_headers: Optional[List[str]] = None,
|
|
57
|
+
match_body: bool = True,
|
|
58
|
+
strict_order: bool = False,
|
|
59
|
+
):
|
|
60
|
+
"""
|
|
61
|
+
Initialize the player.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
file_path: Path to the recordings JSON file.
|
|
65
|
+
config: HttpTestConfig instance with matching settings.
|
|
66
|
+
If provided, other parameters are ignored.
|
|
67
|
+
ignore_headers: Additional headers to ignore during matching.
|
|
68
|
+
(Deprecated: use config instead)
|
|
69
|
+
match_body: Whether to include body in matching.
|
|
70
|
+
(Deprecated: use config instead)
|
|
71
|
+
strict_order: Whether requests must match in recorded order.
|
|
72
|
+
(Deprecated: use config instead)
|
|
73
|
+
"""
|
|
74
|
+
self.file_path = file_path
|
|
75
|
+
self.files_dir = file_path.parent / FILES_DIR
|
|
76
|
+
self.matcher = RequestMatcher(
|
|
77
|
+
config=config,
|
|
78
|
+
ignore_headers=ignore_headers,
|
|
79
|
+
match_body=match_body,
|
|
80
|
+
strict_order=strict_order,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Load mappings
|
|
84
|
+
self.mappings: List[HttpMapping] = []
|
|
85
|
+
self._load_mappings()
|
|
86
|
+
|
|
87
|
+
# Track which mappings have been used
|
|
88
|
+
self.used_indices: Set[int] = set()
|
|
89
|
+
|
|
90
|
+
def _load_body_file(self, filename: str) -> bytes:
|
|
91
|
+
"""
|
|
92
|
+
Load body content from a file in the __files directory.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
filename: Name of the file (not full path)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
File contents as bytes
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
FileNotFoundError: If the file doesn't exist
|
|
102
|
+
"""
|
|
103
|
+
file_path = self.files_dir / filename
|
|
104
|
+
if not file_path.exists():
|
|
105
|
+
raise FileNotFoundError(
|
|
106
|
+
f"Body file not found: {file_path}. "
|
|
107
|
+
f"Referenced in recording but missing from {FILES_DIR}/ directory."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
with open(file_path, "rb") as f:
|
|
111
|
+
return f.read()
|
|
112
|
+
|
|
113
|
+
def _load_mappings(self) -> None:
|
|
114
|
+
"""Load mappings from the JSON file."""
|
|
115
|
+
with open(self.file_path, "r", encoding=ENCODING) as f:
|
|
116
|
+
data = json.load(f)
|
|
117
|
+
|
|
118
|
+
# Parse mappings, converting native JSON bodies to strings
|
|
119
|
+
mappings_data = data.get("mappings", [])
|
|
120
|
+
for mapping_data in mappings_data:
|
|
121
|
+
req_data = mapping_data.get("request", {})
|
|
122
|
+
resp_data = mapping_data.get("response", {})
|
|
123
|
+
|
|
124
|
+
# Deserialize request body (convert JSON objects back to strings)
|
|
125
|
+
req_body = _deserialize_body(req_data.get("body"))
|
|
126
|
+
|
|
127
|
+
request = HttpRequest(
|
|
128
|
+
method=req_data.get("method", "GET"),
|
|
129
|
+
url=req_data.get("url", ""),
|
|
130
|
+
headers=req_data.get("headers", {}),
|
|
131
|
+
queryParameters=req_data.get("queryParameters", {}),
|
|
132
|
+
body=req_body,
|
|
133
|
+
bodyEncoding=req_data.get("bodyEncoding"),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Handle response body - could be inline or file reference
|
|
137
|
+
body_filename = resp_data.get("bodyFileName")
|
|
138
|
+
|
|
139
|
+
if body_filename:
|
|
140
|
+
# Binary body stored in external file
|
|
141
|
+
response = HttpResponse(
|
|
142
|
+
status=resp_data.get("status", 200),
|
|
143
|
+
headers=resp_data.get("headers", {}),
|
|
144
|
+
body=None,
|
|
145
|
+
bodyFileName=body_filename,
|
|
146
|
+
)
|
|
147
|
+
# Load the file content
|
|
148
|
+
response.set_body_bytes(self._load_body_file(body_filename))
|
|
149
|
+
else:
|
|
150
|
+
# Inline body
|
|
151
|
+
resp_body = _deserialize_body(resp_data.get("body"))
|
|
152
|
+
response = HttpResponse(
|
|
153
|
+
status=resp_data.get("status", 200),
|
|
154
|
+
headers=resp_data.get("headers", {}),
|
|
155
|
+
body=resp_body if resp_body is not None else "",
|
|
156
|
+
bodyEncoding=resp_data.get("bodyEncoding"),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
self.mappings.append(HttpMapping(request=request, response=response))
|
|
160
|
+
|
|
161
|
+
def find_response(self, request: HttpRequest) -> HttpResponse:
|
|
162
|
+
"""
|
|
163
|
+
Find and return the recorded response for a request.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
request: The incoming HTTP request.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The matching recorded response.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
NoMatchingRecordingError: If no recording matches the request.
|
|
173
|
+
"""
|
|
174
|
+
result = self.matcher.find_match(request, self.mappings, self.used_indices)
|
|
175
|
+
|
|
176
|
+
if result is None:
|
|
177
|
+
raise NoMatchingRecordingError(
|
|
178
|
+
request=request,
|
|
179
|
+
available_mappings=self.mappings,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
index, mapping = result
|
|
183
|
+
self.used_indices.add(index)
|
|
184
|
+
|
|
185
|
+
return mapping.response
|
|
186
|
+
|
|
187
|
+
def verify_all_used(self) -> None:
|
|
188
|
+
"""
|
|
189
|
+
Verify that all recordings were used.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
UnusedRecordingsError: If any recordings were not matched.
|
|
193
|
+
"""
|
|
194
|
+
unused = [
|
|
195
|
+
mapping
|
|
196
|
+
for i, mapping in enumerate(self.mappings)
|
|
197
|
+
if i not in self.used_indices
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
if unused:
|
|
201
|
+
raise UnusedRecordingsError(unused_mappings=unused)
|
|
202
|
+
|
|
203
|
+
def get_unused_mappings(self) -> List[HttpMapping]:
|
|
204
|
+
"""
|
|
205
|
+
Get list of recordings that haven't been used.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of unused HttpMapping objects.
|
|
209
|
+
"""
|
|
210
|
+
return [
|
|
211
|
+
mapping
|
|
212
|
+
for i, mapping in enumerate(self.mappings)
|
|
213
|
+
if i not in self.used_indices
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
def reset(self) -> None:
|
|
217
|
+
"""Reset usage tracking (for reuse across multiple tests)."""
|
|
218
|
+
self.used_indices.clear()
|
|
219
|
+
|
|
220
|
+
def __len__(self) -> int:
|
|
221
|
+
"""Return number of loaded mappings."""
|
|
222
|
+
return len(self.mappings)
|