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,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)