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.
Files changed (71) hide show
  1. fixturify/__init__.py +21 -0
  2. fixturify/_utils/__init__.py +7 -0
  3. fixturify/_utils/_constants.py +10 -0
  4. fixturify/_utils/_fixture_discovery.py +165 -0
  5. fixturify/_utils/_path_resolver.py +135 -0
  6. fixturify/http_d/__init__.py +80 -0
  7. fixturify/http_d/_config.py +214 -0
  8. fixturify/http_d/_decorator.py +267 -0
  9. fixturify/http_d/_exceptions.py +153 -0
  10. fixturify/http_d/_fixture_discovery.py +33 -0
  11. fixturify/http_d/_matcher.py +372 -0
  12. fixturify/http_d/_mock_context.py +154 -0
  13. fixturify/http_d/_models.py +205 -0
  14. fixturify/http_d/_patcher.py +524 -0
  15. fixturify/http_d/_player.py +222 -0
  16. fixturify/http_d/_recorder.py +1350 -0
  17. fixturify/http_d/_stubs/__init__.py +8 -0
  18. fixturify/http_d/_stubs/_aiohttp.py +220 -0
  19. fixturify/http_d/_stubs/_connection.py +478 -0
  20. fixturify/http_d/_stubs/_httpcore.py +269 -0
  21. fixturify/http_d/_stubs/_tornado.py +95 -0
  22. fixturify/http_d/_utils.py +194 -0
  23. fixturify/json_assert/__init__.py +13 -0
  24. fixturify/json_assert/_actual_saver.py +67 -0
  25. fixturify/json_assert/_assert.py +173 -0
  26. fixturify/json_assert/_comparator.py +183 -0
  27. fixturify/json_assert/_diff_formatter.py +265 -0
  28. fixturify/json_assert/_normalizer.py +83 -0
  29. fixturify/object_mapper/__init__.py +5 -0
  30. fixturify/object_mapper/_deserializers/__init__.py +19 -0
  31. fixturify/object_mapper/_deserializers/_base.py +186 -0
  32. fixturify/object_mapper/_deserializers/_dataclass.py +52 -0
  33. fixturify/object_mapper/_deserializers/_plain.py +55 -0
  34. fixturify/object_mapper/_deserializers/_pydantic_v1.py +38 -0
  35. fixturify/object_mapper/_deserializers/_pydantic_v2.py +41 -0
  36. fixturify/object_mapper/_deserializers/_sqlalchemy.py +72 -0
  37. fixturify/object_mapper/_deserializers/_sqlmodel.py +43 -0
  38. fixturify/object_mapper/_detectors/__init__.py +5 -0
  39. fixturify/object_mapper/_detectors/_type_detector.py +186 -0
  40. fixturify/object_mapper/_serializers/__init__.py +19 -0
  41. fixturify/object_mapper/_serializers/_base.py +260 -0
  42. fixturify/object_mapper/_serializers/_dataclass.py +55 -0
  43. fixturify/object_mapper/_serializers/_plain.py +49 -0
  44. fixturify/object_mapper/_serializers/_pydantic_v1.py +49 -0
  45. fixturify/object_mapper/_serializers/_pydantic_v2.py +49 -0
  46. fixturify/object_mapper/_serializers/_sqlalchemy.py +70 -0
  47. fixturify/object_mapper/_serializers/_sqlmodel.py +54 -0
  48. fixturify/object_mapper/mapper.py +256 -0
  49. fixturify/read_d/__init__.py +5 -0
  50. fixturify/read_d/_decorator.py +193 -0
  51. fixturify/read_d/_fixture_loader.py +88 -0
  52. fixturify/sql_d/__init__.py +7 -0
  53. fixturify/sql_d/_config.py +30 -0
  54. fixturify/sql_d/_decorator.py +373 -0
  55. fixturify/sql_d/_driver_registry.py +133 -0
  56. fixturify/sql_d/_executor.py +82 -0
  57. fixturify/sql_d/_fixture_discovery.py +55 -0
  58. fixturify/sql_d/_phase.py +10 -0
  59. fixturify/sql_d/_strategies/__init__.py +11 -0
  60. fixturify/sql_d/_strategies/_aiomysql.py +63 -0
  61. fixturify/sql_d/_strategies/_aiosqlite.py +29 -0
  62. fixturify/sql_d/_strategies/_asyncpg.py +34 -0
  63. fixturify/sql_d/_strategies/_base.py +118 -0
  64. fixturify/sql_d/_strategies/_mysql.py +70 -0
  65. fixturify/sql_d/_strategies/_psycopg.py +35 -0
  66. fixturify/sql_d/_strategies/_psycopg2.py +40 -0
  67. fixturify/sql_d/_strategies/_registry.py +109 -0
  68. fixturify/sql_d/_strategies/_sqlite.py +33 -0
  69. fixturify-0.1.9.dist-info/METADATA +122 -0
  70. fixturify-0.1.9.dist-info/RECORD +71 -0
  71. fixturify-0.1.9.dist-info/WHEEL +4 -0
@@ -0,0 +1,372 @@
1
+ """Request matching logic for HTTP playback."""
2
+
3
+ import json
4
+ from typing import Any, Dict, List, Optional, Set, Tuple, TYPE_CHECKING
5
+ from urllib.parse import parse_qs, urlparse
6
+
7
+ from fixturify.http_d._models import HttpMapping, HttpRequest
8
+
9
+ if TYPE_CHECKING:
10
+ from fixturify.http_d._config import HttpTestConfig
11
+
12
+
13
+ class RequestMatcher:
14
+ """
15
+ Matches incoming HTTP requests against recorded requests.
16
+
17
+ Provides configurable matching for method, URL, headers, query parameters,
18
+ and request body.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ config: Optional["HttpTestConfig"] = None,
24
+ ignore_headers: Optional[List[str]] = None,
25
+ match_body: bool = True,
26
+ strict_order: bool = False,
27
+ ):
28
+ """
29
+ Initialize the request matcher.
30
+
31
+ Args:
32
+ config: HttpTestConfig instance with matching settings.
33
+ If provided, other parameters are ignored.
34
+ ignore_headers: Additional headers to ignore during matching.
35
+ These are added to the default ignored headers.
36
+ (Deprecated: use config instead)
37
+ match_body: Whether to include body in matching.
38
+ (Deprecated: use config instead)
39
+ strict_order: Whether requests must match in recorded order.
40
+ (Deprecated: use config instead)
41
+ """
42
+ if config is not None:
43
+ self.ignore_headers = config.get_ignore_request_headers_set()
44
+ self.match_body = config.match_request_body
45
+ self.strict_order = config.strict_order
46
+ self.redact_request_body = {f for f in config.redact_request_body if f}
47
+ else:
48
+ from ._config import DEFAULT_IGNORE_REQUEST_HEADERS
49
+
50
+ self.ignore_headers = DEFAULT_IGNORE_REQUEST_HEADERS.copy()
51
+ if ignore_headers:
52
+ self.ignore_headers.update(h.lower() for h in ignore_headers)
53
+ self.match_body = match_body
54
+ self.strict_order = strict_order
55
+ self.redact_request_body = set()
56
+
57
+ def find_match(
58
+ self,
59
+ request: HttpRequest,
60
+ mappings: List[HttpMapping],
61
+ used_indices: Set[int],
62
+ ) -> Optional[Tuple[int, HttpMapping]]:
63
+ """
64
+ Find a matching mapping for the given request.
65
+
66
+ Args:
67
+ request: The incoming request to match.
68
+ mappings: List of available mappings.
69
+ used_indices: Set of indices that have already been used.
70
+
71
+ Returns:
72
+ Tuple of (index, mapping) if match found, None otherwise.
73
+ """
74
+ if self.strict_order:
75
+ return self._find_match_strict_order(request, mappings, used_indices)
76
+ return self._find_match_any_order(request, mappings, used_indices)
77
+
78
+ def _find_match_any_order(
79
+ self,
80
+ request: HttpRequest,
81
+ mappings: List[HttpMapping],
82
+ used_indices: Set[int],
83
+ ) -> Optional[Tuple[int, HttpMapping]]:
84
+ """Find match allowing any order."""
85
+ for i, mapping in enumerate(mappings):
86
+ if i in used_indices:
87
+ continue
88
+
89
+ if self._matches(request, mapping.request):
90
+ return (i, mapping)
91
+
92
+ return None
93
+
94
+ def _find_match_strict_order(
95
+ self,
96
+ request: HttpRequest,
97
+ mappings: List[HttpMapping],
98
+ used_indices: Set[int],
99
+ ) -> Optional[Tuple[int, HttpMapping]]:
100
+ """Find match requiring strict order."""
101
+ # Find the first unused mapping
102
+ for i, mapping in enumerate(mappings):
103
+ if i in used_indices:
104
+ continue
105
+
106
+ # In strict mode, must match the next unused mapping
107
+ if self._matches(request, mapping.request):
108
+ return (i, mapping)
109
+ else:
110
+ # Strict order: if first unused doesn't match, fail
111
+ return None
112
+
113
+ return None
114
+
115
+ def _matches(self, actual: HttpRequest, expected: HttpRequest) -> bool:
116
+ """
117
+ Check if actual request matches expected request.
118
+
119
+ Args:
120
+ actual: The incoming request.
121
+ expected: The recorded request.
122
+
123
+ Returns:
124
+ True if requests match, False otherwise.
125
+ """
126
+ # Method must match (case-insensitive)
127
+ if actual.method.upper() != expected.method.upper():
128
+ return False
129
+
130
+ # URL must match (normalized)
131
+ if not self._urls_match(actual.url, expected.url):
132
+ return False
133
+
134
+ # Query parameters must match
135
+ if not self._query_params_match(
136
+ actual.queryParameters,
137
+ expected.queryParameters,
138
+ actual.url,
139
+ expected.url,
140
+ ):
141
+ return False
142
+
143
+ # Headers must match (considering ignored headers)
144
+ if not self._headers_match(actual.headers, expected.headers):
145
+ return False
146
+
147
+ # Body must match (if enabled)
148
+ if self.match_body and not self._body_matches(actual.body, expected.body):
149
+ return False
150
+
151
+ return True
152
+
153
+ def _urls_match(self, actual_url: str, expected_url: str) -> bool:
154
+ """
155
+ Check if URLs match, ignoring query string.
156
+
157
+ Args:
158
+ actual_url: The actual URL.
159
+ expected_url: The expected URL.
160
+
161
+ Returns:
162
+ True if URLs match (scheme + host + path).
163
+ """
164
+ actual_parsed = urlparse(actual_url)
165
+ expected_parsed = urlparse(expected_url)
166
+
167
+ # Compare scheme, netloc (host:port), and path
168
+ return (
169
+ actual_parsed.scheme == expected_parsed.scheme
170
+ and actual_parsed.netloc == expected_parsed.netloc
171
+ and actual_parsed.path == expected_parsed.path
172
+ )
173
+
174
+ def _query_params_match(
175
+ self,
176
+ actual_params: Dict[str, str],
177
+ expected_params: Dict[str, str],
178
+ actual_url: str,
179
+ expected_url: str,
180
+ ) -> bool:
181
+ """
182
+ Check if query parameters match.
183
+
184
+ Compares both explicit queryParameters dict and URL query string.
185
+
186
+ Args:
187
+ actual_params: Actual query parameters dict.
188
+ expected_params: Expected query parameters dict.
189
+ actual_url: Actual URL (may contain query string).
190
+ expected_url: Expected URL (may contain query string).
191
+
192
+ Returns:
193
+ True if query parameters match.
194
+ """
195
+ # Merge URL query params with explicit params (explicit takes precedence)
196
+ actual_merged = self._extract_and_merge_params(actual_url, actual_params)
197
+ expected_merged = self._extract_and_merge_params(expected_url, expected_params)
198
+
199
+ return actual_merged == expected_merged
200
+
201
+ def _extract_and_merge_params(
202
+ self,
203
+ url: str,
204
+ explicit_params: Dict[str, str],
205
+ ) -> Dict[str, str]:
206
+ """
207
+ Extract query params from URL and merge with explicit params.
208
+
209
+ Args:
210
+ url: URL that may contain query string.
211
+ explicit_params: Explicitly provided query parameters.
212
+
213
+ Returns:
214
+ Merged query parameters dict.
215
+ """
216
+ parsed = urlparse(url)
217
+ url_params = parse_qs(parsed.query)
218
+
219
+ # Flatten parse_qs result (which returns lists)
220
+ result: Dict[str, str] = {}
221
+ for key, values in url_params.items():
222
+ # Take first value (most common case)
223
+ result[key] = values[0] if values else ""
224
+
225
+ # Explicit params override URL params
226
+ result.update(explicit_params)
227
+
228
+ return result
229
+
230
+ def _headers_match(
231
+ self,
232
+ actual_headers: Dict[str, str],
233
+ expected_headers: Dict[str, str],
234
+ ) -> bool:
235
+ """
236
+ Check if headers match, ignoring specified headers.
237
+
238
+ All expected headers must be present with matching values.
239
+ Extra headers in actual request are allowed.
240
+
241
+ Args:
242
+ actual_headers: Actual request headers.
243
+ expected_headers: Expected request headers.
244
+
245
+ Returns:
246
+ True if all expected headers are present with correct values.
247
+ """
248
+ # Normalize header names to lowercase
249
+ actual_normalized = {k.lower(): v for k, v in actual_headers.items()}
250
+ expected_normalized = {k.lower(): v for k, v in expected_headers.items()}
251
+
252
+ # Check each expected header
253
+ for key, expected_value in expected_normalized.items():
254
+ # Skip ignored headers
255
+ if key in self.ignore_headers:
256
+ continue
257
+
258
+ # Header must be present
259
+ if key not in actual_normalized:
260
+ return False
261
+
262
+ # Value must match
263
+ if actual_normalized[key] != expected_value:
264
+ return False
265
+
266
+ return True
267
+
268
+ def _body_matches(
269
+ self,
270
+ actual_body: Optional[str],
271
+ expected_body: Optional[str],
272
+ ) -> bool:
273
+ """
274
+ Check if request bodies match.
275
+
276
+ Attempts JSON comparison for JSON content, falls back to string comparison.
277
+
278
+ Args:
279
+ actual_body: Actual request body.
280
+ expected_body: Expected request body.
281
+
282
+ Returns:
283
+ True if bodies match.
284
+ """
285
+ # Handle None/empty cases
286
+ if not actual_body and not expected_body:
287
+ return True
288
+ if not actual_body or not expected_body:
289
+ return False
290
+
291
+ # Try JSON comparison (order-independent for objects)
292
+ try:
293
+ actual_json = json.loads(actual_body)
294
+ expected_json = json.loads(expected_body)
295
+ if self.redact_request_body:
296
+ actual_json = self._redact_json_fields(actual_json)
297
+ expected_json = self._redact_json_fields(expected_json)
298
+ return actual_json == expected_json
299
+ except (json.JSONDecodeError, TypeError):
300
+ pass
301
+
302
+ # Fall back to string comparison
303
+ return actual_body == expected_body
304
+
305
+ def _redact_json_fields(self, value: Any) -> Any:
306
+ """Redact configured JSON fields in dict/list structures."""
307
+ if not self.redact_request_body:
308
+ return value
309
+ if isinstance(value, dict):
310
+ redacted = {}
311
+ for key, val in value.items():
312
+ if key in self.redact_request_body:
313
+ redacted[key] = "*******"
314
+ else:
315
+ redacted[key] = self._redact_json_fields(val)
316
+ return redacted
317
+ if isinstance(value, list):
318
+ return [self._redact_json_fields(item) for item in value]
319
+ return value
320
+
321
+ def get_mismatch_details(
322
+ self,
323
+ actual: HttpRequest,
324
+ expected: HttpRequest,
325
+ ) -> Dict[str, Tuple[Any, Any]]:
326
+ """
327
+ Get detailed mismatch information between two requests.
328
+
329
+ Useful for error messages when a close match is found but not exact.
330
+
331
+ Args:
332
+ actual: The actual request.
333
+ expected: The expected request.
334
+
335
+ Returns:
336
+ Dict mapping field names to (actual_value, expected_value) tuples.
337
+ """
338
+ mismatches: Dict[str, Tuple[Any, Any]] = {}
339
+
340
+ if actual.method.upper() != expected.method.upper():
341
+ mismatches["method"] = (actual.method, expected.method)
342
+
343
+ if not self._urls_match(actual.url, expected.url):
344
+ mismatches["url"] = (actual.url, expected.url)
345
+
346
+ actual_params = self._extract_and_merge_params(
347
+ actual.url, actual.queryParameters
348
+ )
349
+ expected_params = self._extract_and_merge_params(
350
+ expected.url, expected.queryParameters
351
+ )
352
+ if actual_params != expected_params:
353
+ mismatches["queryParameters"] = (actual_params, expected_params)
354
+
355
+ if not self._headers_match(actual.headers, expected.headers):
356
+ # Filter to show only relevant header differences
357
+ actual_filtered = {
358
+ k.lower(): v
359
+ for k, v in actual.headers.items()
360
+ if k.lower() not in self.ignore_headers
361
+ }
362
+ expected_filtered = {
363
+ k.lower(): v
364
+ for k, v in expected.headers.items()
365
+ if k.lower() not in self.ignore_headers
366
+ }
367
+ mismatches["headers"] = (actual_filtered, expected_filtered)
368
+
369
+ if self.match_body and not self._body_matches(actual.body, expected.body):
370
+ mismatches["body"] = (actual.body, expected.body)
371
+
372
+ return mismatches
@@ -0,0 +1,154 @@
1
+ """HTTP mock context for recording and playback."""
2
+
3
+ from contextlib import ExitStack
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING, Optional
6
+
7
+ from ._player import HttpPlayer
8
+ from ._recorder import HttpRecorder
9
+
10
+ if TYPE_CHECKING:
11
+ from ._config import HttpTestConfig
12
+ from ._models import HttpRequest, HttpResponse
13
+
14
+
15
+ class HttpMockContext:
16
+ """
17
+ Manages HTTP recording/playback for a single test.
18
+
19
+ This is the main entry point for HTTP mocking. It handles:
20
+ - Determining record vs playback mode
21
+ - Installing/uninstalling patches on HTTP clients
22
+ - Recording request/response pairs
23
+ - Playing back recorded responses
24
+ - Verifying all recordings were used
25
+
26
+ Usage:
27
+ with HttpMockContext(path, config):
28
+ # HTTP calls are intercepted here
29
+ response = requests.get("https://api.example.com")
30
+ """
31
+
32
+ def __init__(self, path: Path, config: "HttpTestConfig"):
33
+ """
34
+ Initialize the mock context.
35
+
36
+ Args:
37
+ path: Path to the fixture file for recording/playback
38
+ config: Configuration for HTTP mocking behavior
39
+ """
40
+ self.path = path
41
+ self.config = config
42
+ self.mode = "playback" if path.exists() else "record"
43
+
44
+ # Initialize recorder or player based on mode
45
+ if self.mode == "record":
46
+ self._recorder: Optional[HttpRecorder] = HttpRecorder(
47
+ path,
48
+ exclude_request_headers=config.get_exclude_request_headers_set(),
49
+ exclude_response_headers=config.get_exclude_response_headers_set(),
50
+ redact_request_body=config.redact_request_body,
51
+ redact_response_body=config.redact_response_body,
52
+ )
53
+ self._player: Optional[HttpPlayer] = None
54
+ else:
55
+ self._recorder = None
56
+ self._player = HttpPlayer(path, config=config)
57
+
58
+ # Patch management
59
+ self._exit_stack: Optional[ExitStack] = None
60
+
61
+ def __enter__(self) -> "HttpMockContext":
62
+ """Start HTTP interception by installing patches."""
63
+ from ._patcher import PatcherBuilder
64
+
65
+ self._exit_stack = ExitStack()
66
+
67
+ # Build and start all patches
68
+ patcher = PatcherBuilder(self)
69
+ for patch in patcher.build():
70
+ self._exit_stack.enter_context(patch)
71
+
72
+ return self
73
+
74
+ def __exit__(self, exc_type, exc_val, exc_tb):
75
+ """Stop HTTP interception and finalize recording/playback."""
76
+ # Stop all patches
77
+ if self._exit_stack:
78
+ self._exit_stack.close()
79
+ self._exit_stack = None
80
+
81
+ # Handle recording/playback finalization
82
+ if self.mode == "record" and self._recorder is not None:
83
+ self._recorder.save()
84
+ elif self.mode == "playback" and self._player and exc_type is None:
85
+ # Only verify if no exception occurred (don't mask original error)
86
+ self._player.verify_all_used()
87
+
88
+ return False # Don't suppress exceptions
89
+
90
+ def can_play_response_for(self, request: "HttpRequest") -> bool:
91
+ """
92
+ Check if a recorded response exists for the given request.
93
+
94
+ Args:
95
+ request: The HTTP request to check
96
+
97
+ Returns:
98
+ True if in playback mode and a matching response exists
99
+ """
100
+ if self._player is None:
101
+ return False
102
+
103
+ # Try to find a match without consuming it
104
+ try:
105
+ result = self._player.matcher.find_match(
106
+ request,
107
+ self._player.mappings,
108
+ self._player.used_indices
109
+ )
110
+ return result is not None
111
+ except Exception:
112
+ return False
113
+
114
+ def play_response(self, request: "HttpRequest") -> "HttpResponse":
115
+ """
116
+ Get the recorded response for a request.
117
+
118
+ Args:
119
+ request: The HTTP request to match
120
+
121
+ Returns:
122
+ The matching recorded response
123
+
124
+ Raises:
125
+ NoMatchingRecordingError: If no matching recording found
126
+ """
127
+ if self._player is None:
128
+ from ._exceptions import NoMatchingRecordingError
129
+ raise NoMatchingRecordingError(request=request, available_mappings=[])
130
+
131
+ return self._player.find_response(request)
132
+
133
+ def record(self, request: "HttpRequest", response: "HttpResponse") -> None:
134
+ """
135
+ Record a request/response pair.
136
+
137
+ Args:
138
+ request: The HTTP request
139
+ response: The HTTP response
140
+ """
141
+ if self._recorder is not None:
142
+ self._recorder.record(request, response)
143
+
144
+ def is_host_excluded(self, url: str) -> bool:
145
+ """
146
+ Check if a URL's host is excluded from mocking.
147
+
148
+ Args:
149
+ url: The URL to check
150
+
151
+ Returns:
152
+ True if the host should not be mocked
153
+ """
154
+ return self.config.is_host_excluded(url)