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,70 @@
|
|
|
1
|
+
"""SQLAlchemy model serializer."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fixturify.object_mapper._serializers._base import _BaseSerializer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _SQLAlchemySerializer(_BaseSerializer):
|
|
9
|
+
"""Serializer for SQLAlchemy models."""
|
|
10
|
+
|
|
11
|
+
def serialize(self, obj: Any) -> dict:
|
|
12
|
+
"""
|
|
13
|
+
Serialize a SQLAlchemy model to a dictionary.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
obj: A SQLAlchemy model instance
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Dictionary representation of the model
|
|
20
|
+
"""
|
|
21
|
+
self.reset()
|
|
22
|
+
return self._serialize_with_path(obj, "#")
|
|
23
|
+
|
|
24
|
+
def _serialize_object(self, obj: Any, path: str) -> dict:
|
|
25
|
+
"""Serialize a SQLAlchemy model."""
|
|
26
|
+
obj_type = type(obj)
|
|
27
|
+
|
|
28
|
+
# Check if this is a SQLAlchemy model
|
|
29
|
+
if hasattr(obj_type, "__tablename__") and hasattr(obj_type, "__mapper__"):
|
|
30
|
+
return self._serialize_sqlalchemy(obj, path)
|
|
31
|
+
|
|
32
|
+
# Fall back to base implementation for nested non-SQLAlchemy objects
|
|
33
|
+
return super()._serialize_object(obj, path)
|
|
34
|
+
|
|
35
|
+
def _serialize_sqlalchemy(self, obj: Any, path: str) -> dict:
|
|
36
|
+
"""Serialize a SQLAlchemy model using its mapper columns."""
|
|
37
|
+
result = {}
|
|
38
|
+
|
|
39
|
+
# Get the mapper for column information
|
|
40
|
+
mapper = getattr(type(obj), "__mapper__", None)
|
|
41
|
+
if mapper is None:
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
# Iterate over column properties
|
|
45
|
+
for column in mapper.columns:
|
|
46
|
+
column_name = column.key
|
|
47
|
+
|
|
48
|
+
# Skip dunder fields
|
|
49
|
+
if self._is_dunder(column_name):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
value = getattr(obj, column_name, None)
|
|
53
|
+
item_path = f"{path}/{column_name}"
|
|
54
|
+
result[column_name] = self._serialize_value(value, item_path)
|
|
55
|
+
|
|
56
|
+
# Also handle relationships if loaded (not lazy)
|
|
57
|
+
for relationship in mapper.relationships:
|
|
58
|
+
rel_name = relationship.key
|
|
59
|
+
|
|
60
|
+
# Skip dunder fields
|
|
61
|
+
if self._is_dunder(rel_name):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# Check if the relationship is loaded
|
|
65
|
+
if rel_name in obj.__dict__:
|
|
66
|
+
value = getattr(obj, rel_name, None)
|
|
67
|
+
item_path = f"{path}/{rel_name}"
|
|
68
|
+
result[rel_name] = self._serialize_value(value, item_path)
|
|
69
|
+
|
|
70
|
+
return result
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""SQLModel model serializer."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fixturify.object_mapper._serializers._base import _BaseSerializer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _SQLModelSerializer(_BaseSerializer):
|
|
9
|
+
"""Serializer for SQLModel models."""
|
|
10
|
+
|
|
11
|
+
def serialize(self, obj: Any) -> dict:
|
|
12
|
+
"""
|
|
13
|
+
Serialize a SQLModel model to a dictionary.
|
|
14
|
+
|
|
15
|
+
SQLModel combines Pydantic v2 and SQLAlchemy, so we use
|
|
16
|
+
Pydantic's model_fields for serialization.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
obj: A SQLModel model instance
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Dictionary representation of the model
|
|
23
|
+
"""
|
|
24
|
+
self.reset()
|
|
25
|
+
return self._serialize_with_path(obj, "#")
|
|
26
|
+
|
|
27
|
+
def _serialize_object(self, obj: Any, path: str) -> dict:
|
|
28
|
+
"""Serialize a SQLModel model."""
|
|
29
|
+
obj_type = type(obj)
|
|
30
|
+
|
|
31
|
+
# Check if this is a SQLModel model (has both __tablename__ and model_fields)
|
|
32
|
+
if hasattr(obj_type, "__tablename__") and hasattr(obj_type, "model_fields"):
|
|
33
|
+
return self._serialize_sqlmodel(obj, path)
|
|
34
|
+
|
|
35
|
+
# Fall back to base implementation for nested non-SQLModel objects
|
|
36
|
+
return super()._serialize_object(obj, path)
|
|
37
|
+
|
|
38
|
+
def _serialize_sqlmodel(self, obj: Any, path: str) -> dict:
|
|
39
|
+
"""Serialize a SQLModel using its model_fields (Pydantic v2 style)."""
|
|
40
|
+
result = {}
|
|
41
|
+
|
|
42
|
+
# Get fields from model_fields (Pydantic v2)
|
|
43
|
+
model_fields = getattr(type(obj), "model_fields", {})
|
|
44
|
+
|
|
45
|
+
for field_name in model_fields:
|
|
46
|
+
# Skip dunder fields
|
|
47
|
+
if self._is_dunder(field_name):
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
value = getattr(obj, field_name)
|
|
51
|
+
item_path = f"{path}/{field_name}"
|
|
52
|
+
result[field_name] = self._serialize_value(value, item_path)
|
|
53
|
+
|
|
54
|
+
return result
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Main ObjectMapper class for bidirectional object-to-JSON mapping."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, List, Optional, Type, TypeVar, Union
|
|
6
|
+
|
|
7
|
+
from fixturify.object_mapper._detectors import _TypeDetector, ObjectType
|
|
8
|
+
from fixturify.object_mapper._serializers import (
|
|
9
|
+
_BaseSerializer,
|
|
10
|
+
_DataclassSerializer,
|
|
11
|
+
_PlainObjectSerializer,
|
|
12
|
+
_PydanticV1Serializer,
|
|
13
|
+
_PydanticV2Serializer,
|
|
14
|
+
_SQLAlchemySerializer,
|
|
15
|
+
_SQLModelSerializer,
|
|
16
|
+
)
|
|
17
|
+
from fixturify.object_mapper._deserializers import (
|
|
18
|
+
_DataclassDeserializer,
|
|
19
|
+
_PlainObjectDeserializer,
|
|
20
|
+
_PydanticV1Deserializer,
|
|
21
|
+
_PydanticV2Deserializer,
|
|
22
|
+
_SQLAlchemyDeserializer,
|
|
23
|
+
_SQLModelDeserializer,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ObjectMapper:
|
|
30
|
+
"""
|
|
31
|
+
Universal object-to-JSON mapper supporting multiple object types.
|
|
32
|
+
|
|
33
|
+
Supports:
|
|
34
|
+
- Plain Python objects
|
|
35
|
+
- Dataclasses
|
|
36
|
+
- Pydantic v1 models
|
|
37
|
+
- Pydantic v2 models
|
|
38
|
+
- SQLAlchemy models
|
|
39
|
+
- SQLModel models
|
|
40
|
+
- Collections (list, tuple, set)
|
|
41
|
+
- Dictionaries
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Serializer instances (lazy-loaded)
|
|
45
|
+
_serializers = {
|
|
46
|
+
ObjectType.DATACLASS: _DataclassSerializer,
|
|
47
|
+
ObjectType.PYDANTIC_V1: _PydanticV1Serializer,
|
|
48
|
+
ObjectType.PYDANTIC_V2: _PydanticV2Serializer,
|
|
49
|
+
ObjectType.SQLALCHEMY: _SQLAlchemySerializer,
|
|
50
|
+
ObjectType.SQLMODEL: _SQLModelSerializer,
|
|
51
|
+
ObjectType.PLAIN_OBJECT: _PlainObjectSerializer,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Deserializer instances (lazy-loaded)
|
|
55
|
+
_deserializers = {
|
|
56
|
+
ObjectType.DATACLASS: _DataclassDeserializer,
|
|
57
|
+
ObjectType.PYDANTIC_V1: _PydanticV1Deserializer,
|
|
58
|
+
ObjectType.PYDANTIC_V2: _PydanticV2Deserializer,
|
|
59
|
+
ObjectType.SQLALCHEMY: _SQLAlchemyDeserializer,
|
|
60
|
+
ObjectType.SQLMODEL: _SQLModelDeserializer,
|
|
61
|
+
ObjectType.PLAIN_OBJECT: _PlainObjectDeserializer,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Flag to track if serializer registry has been set
|
|
65
|
+
_registry_initialized = False
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def _ensure_registry_initialized(cls) -> None:
|
|
69
|
+
"""Initialize the serializer registry for nested object routing."""
|
|
70
|
+
if not cls._registry_initialized:
|
|
71
|
+
_BaseSerializer.set_serializer_registry(cls._serializers)
|
|
72
|
+
cls._registry_initialized = True
|
|
73
|
+
|
|
74
|
+
def __init__(self, data: Any) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Initialize ObjectMapper with data.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
data: Any data - object, collection, dict, or JSON string
|
|
80
|
+
"""
|
|
81
|
+
# Ensure serializer registry is initialized for nested type routing
|
|
82
|
+
ObjectMapper._ensure_registry_initialized()
|
|
83
|
+
|
|
84
|
+
self._data = data
|
|
85
|
+
self._data_type = _TypeDetector.detect(data)
|
|
86
|
+
|
|
87
|
+
def to_json(self) -> Any:
|
|
88
|
+
"""
|
|
89
|
+
Convert stored object/collection to JSON-compatible value.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
JSON-compatible value: dict, list, or primitive (str, int, float, bool, None)
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If serialization fails
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
return self._serialize(self._data)
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise ValueError(f"Serialization failed: {e}") from e
|
|
101
|
+
|
|
102
|
+
def to_json_string(self, indent: Optional[int] = None) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Convert stored object/collection to JSON string.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
indent: Optional indentation level for pretty printing
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
JSON string representation
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If serialization fails
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
serialized = self._serialize(self._data)
|
|
117
|
+
return json.dumps(serialized, indent=indent, ensure_ascii=False)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
raise ValueError(f"Serialization failed: {e}") from e
|
|
120
|
+
|
|
121
|
+
def to_object(self, target_class: Type[T]) -> Union[T, List[T]]:
|
|
122
|
+
"""
|
|
123
|
+
Convert stored JSON/dict to object or list of objects.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
target_class: The class to deserialize into
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Instance of target_class, or list of instances if input is a list
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValueError: If deserialization fails
|
|
133
|
+
TypeError: If target_class is unsupported
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
# Parse JSON string if needed
|
|
137
|
+
if self._data_type == ObjectType.JSON_STRING:
|
|
138
|
+
data = json.loads(self._data)
|
|
139
|
+
# If parsed data is a primitive (e.g., string for enum), handle it
|
|
140
|
+
if not isinstance(data, (dict, list)):
|
|
141
|
+
return self._deserialize_primitive(data, target_class)
|
|
142
|
+
elif self._data_type == ObjectType.DICT:
|
|
143
|
+
data = self._data
|
|
144
|
+
elif self._data_type == ObjectType.COLLECTION:
|
|
145
|
+
# If it's already a collection, use it directly
|
|
146
|
+
data = list(self._data)
|
|
147
|
+
elif self._data_type == ObjectType.PRIMITIVE:
|
|
148
|
+
# Handle primitive types (for Enum deserialization)
|
|
149
|
+
return self._deserialize_primitive(self._data, target_class)
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"to_object() requires JSON string, dict, or collection. "
|
|
153
|
+
f"Got: {self._data_type}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Handle list of objects
|
|
157
|
+
if isinstance(data, list):
|
|
158
|
+
return self._deserialize_list(data, target_class)
|
|
159
|
+
|
|
160
|
+
# Handle single object
|
|
161
|
+
return self._deserialize(data, target_class)
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
if isinstance(e, (ValueError, TypeError)):
|
|
165
|
+
raise
|
|
166
|
+
raise ValueError(f"Deserialization failed: {e}") from e
|
|
167
|
+
|
|
168
|
+
def _serialize(self, data: Any) -> Any:
|
|
169
|
+
"""
|
|
170
|
+
Serialize data to a JSON-compatible format.
|
|
171
|
+
|
|
172
|
+
Uses a single serializer instance for the entire serialization to ensure
|
|
173
|
+
shared reference tracking (via $ref) works correctly across all items
|
|
174
|
+
in root-level dicts and collections.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
data: Data to serialize
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
JSON-compatible dict, list, or primitive
|
|
181
|
+
"""
|
|
182
|
+
data_type = _TypeDetector.detect(data)
|
|
183
|
+
|
|
184
|
+
# Handle primitives directly (no need for serializer)
|
|
185
|
+
if data_type == ObjectType.PRIMITIVE:
|
|
186
|
+
if isinstance(data, Enum):
|
|
187
|
+
return data.value
|
|
188
|
+
return data
|
|
189
|
+
|
|
190
|
+
# Handle JSON string (return parsed)
|
|
191
|
+
if data_type == ObjectType.JSON_STRING:
|
|
192
|
+
return json.loads(data)
|
|
193
|
+
|
|
194
|
+
# For dicts, collections, and objects, use a single serializer instance
|
|
195
|
+
# to maintain shared visited state across the entire serialization tree.
|
|
196
|
+
# This ensures $ref works correctly for shared objects in root collections/dicts.
|
|
197
|
+
serializer = _PlainObjectSerializer()
|
|
198
|
+
serializer.reset()
|
|
199
|
+
return serializer._serialize_value(data, "#")
|
|
200
|
+
|
|
201
|
+
def _deserialize(self, data: dict, target_class: Type[T]) -> T:
|
|
202
|
+
"""
|
|
203
|
+
Deserialize a dict to an object.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
data: Dictionary data
|
|
207
|
+
target_class: Target class
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Instance of target_class
|
|
211
|
+
"""
|
|
212
|
+
# Detect target class type
|
|
213
|
+
class_type = _TypeDetector.detect_class(target_class)
|
|
214
|
+
|
|
215
|
+
# Get appropriate deserializer
|
|
216
|
+
deserializer_class = self._deserializers.get(class_type)
|
|
217
|
+
if deserializer_class is None:
|
|
218
|
+
raise TypeError(f"Unsupported target class type: {class_type}")
|
|
219
|
+
|
|
220
|
+
deserializer = deserializer_class()
|
|
221
|
+
return deserializer.deserialize(data, target_class)
|
|
222
|
+
|
|
223
|
+
def _deserialize_list(self, data: list, target_class: Type[T]) -> List[T]:
|
|
224
|
+
"""
|
|
225
|
+
Deserialize a list to a list of objects.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
data: List of items (dicts for objects, or primitives for Enums)
|
|
229
|
+
target_class: Target class for each item
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
List of target_class instances
|
|
233
|
+
"""
|
|
234
|
+
result = []
|
|
235
|
+
for item in data:
|
|
236
|
+
if isinstance(item, dict):
|
|
237
|
+
result.append(self._deserialize(item, target_class))
|
|
238
|
+
else:
|
|
239
|
+
# Handle primitives (e.g., list of Enums or simple values)
|
|
240
|
+
result.append(self._deserialize_primitive(item, target_class))
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
def _deserialize_primitive(self, data: Any, target_class: Type[T]) -> T:
|
|
244
|
+
"""
|
|
245
|
+
Deserialize a primitive value to a target class (e.g., Enum).
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
data: Primitive value
|
|
249
|
+
target_class: Target class (e.g., Enum subclass)
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Instance of target_class
|
|
253
|
+
"""
|
|
254
|
+
if isinstance(target_class, type) and issubclass(target_class, Enum):
|
|
255
|
+
return target_class(data)
|
|
256
|
+
return data
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Read decorator implementation for injecting test fixtures."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Callable, Optional, Type, TypeVar, List
|
|
7
|
+
|
|
8
|
+
from fixturify.read_d._fixture_loader import _FixtureLoader
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _unwrap_func(func: Callable) -> Callable:
|
|
14
|
+
"""Return the original function by walking the __wrapped__ chain."""
|
|
15
|
+
original = func
|
|
16
|
+
while hasattr(original, "__wrapped__"):
|
|
17
|
+
original = original.__wrapped__
|
|
18
|
+
return original
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_base_signature(func: Callable) -> inspect.Signature:
|
|
22
|
+
"""Return the most accurate signature, honoring custom __signature__."""
|
|
23
|
+
if hasattr(func, "__signature__"):
|
|
24
|
+
return func.__signature__ # type: ignore
|
|
25
|
+
return inspect.signature(func)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _create_new_signature(
|
|
29
|
+
original_sig: inspect.Signature, fixture_names: List[str]
|
|
30
|
+
) -> inspect.Signature:
|
|
31
|
+
"""
|
|
32
|
+
Create a new signature that excludes the fixture parameter names.
|
|
33
|
+
|
|
34
|
+
This prevents pytest from trying to inject these parameters as pytest fixtures.
|
|
35
|
+
"""
|
|
36
|
+
new_params = [
|
|
37
|
+
param
|
|
38
|
+
for param in original_sig.parameters.values()
|
|
39
|
+
if param.name not in fixture_names
|
|
40
|
+
]
|
|
41
|
+
return original_sig.replace(parameters=new_params)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class read:
|
|
45
|
+
"""
|
|
46
|
+
Namespace class for read decorators.
|
|
47
|
+
|
|
48
|
+
Provides the @read.fixture() decorator for injecting JSON file data
|
|
49
|
+
into test functions.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def fixture(
|
|
54
|
+
path: str, fixture_name: str, object_class: Optional[Type[T]] = None
|
|
55
|
+
) -> Callable:
|
|
56
|
+
"""
|
|
57
|
+
Decorator that injects a JSON file as a typed object into a test function.
|
|
58
|
+
|
|
59
|
+
This decorator loads JSON data from a file and injects it as a named
|
|
60
|
+
parameter into the decorated function. The path is resolved relative
|
|
61
|
+
to the test file's location.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
path: Relative path to JSON file (from test file location)
|
|
65
|
+
fixture_name: Name of the parameter to inject
|
|
66
|
+
object_class: Optional class to deserialize JSON into (via ObjectMapper).
|
|
67
|
+
If None, returns raw dict/list from JSON.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Decorated function with fixture injected
|
|
71
|
+
|
|
72
|
+
Example:
|
|
73
|
+
@read.fixture(path="./fixtures/user.json", fixture_name="user", object_class=User)
|
|
74
|
+
def test_user_creation(user: User):
|
|
75
|
+
assert user.name == "John"
|
|
76
|
+
|
|
77
|
+
Note:
|
|
78
|
+
This is NOT a pytest fixture. It's a pure Python function wrapper
|
|
79
|
+
that works with any test framework (pytest, unittest, nose, etc.).
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def decorator(func: Callable) -> Callable:
|
|
83
|
+
# Get the test file path at decoration time
|
|
84
|
+
# If the function is already wrapped by another @read.fixture,
|
|
85
|
+
# use the stored test file path from the first decorator
|
|
86
|
+
if hasattr(func, "__pytools_test_file__"):
|
|
87
|
+
test_file_path = func.__pytools_test_file__
|
|
88
|
+
else:
|
|
89
|
+
test_file_path = inspect.getfile(_unwrap_func(func))
|
|
90
|
+
|
|
91
|
+
# Get the original function's signature for validation
|
|
92
|
+
original_func = _unwrap_func(func)
|
|
93
|
+
original_sig = inspect.signature(original_func)
|
|
94
|
+
original_params = set(original_sig.parameters.keys())
|
|
95
|
+
|
|
96
|
+
# Check if function accepts **kwargs (VAR_KEYWORD)
|
|
97
|
+
has_var_keyword = any(
|
|
98
|
+
param.kind == inspect.Parameter.VAR_KEYWORD
|
|
99
|
+
for param in original_sig.parameters.values()
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Validate that fixture_name exists in the function signature
|
|
103
|
+
# (excluding 'self' for methods, and skip if **kwargs is present)
|
|
104
|
+
if not has_var_keyword:
|
|
105
|
+
params_to_check = original_params - {"self", "cls"}
|
|
106
|
+
if fixture_name not in params_to_check:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"Fixture name '{fixture_name}' not found in function '{func.__name__}' signature. "
|
|
109
|
+
f"Available parameters: {', '.join(sorted(params_to_check)) or 'none'}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Get or create fixtures list on the function
|
|
113
|
+
if not hasattr(func, "__pytools_fixtures__"):
|
|
114
|
+
func.__pytools_fixtures__ = []
|
|
115
|
+
|
|
116
|
+
# Check for duplicate fixture names across stacked decorators
|
|
117
|
+
existing_names = [
|
|
118
|
+
loader.fixture_name
|
|
119
|
+
for loader in getattr(func, "__pytools_fixtures__", [])
|
|
120
|
+
]
|
|
121
|
+
if fixture_name in existing_names:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Duplicate fixture name '{fixture_name}' in decorators "
|
|
124
|
+
f"for function '{func.__name__}'"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Create and add the fixture loader
|
|
128
|
+
loader = _FixtureLoader(
|
|
129
|
+
path=path,
|
|
130
|
+
fixture_name=fixture_name,
|
|
131
|
+
object_class=object_class,
|
|
132
|
+
test_file_path=test_file_path,
|
|
133
|
+
)
|
|
134
|
+
func.__pytools_fixtures__.append(loader)
|
|
135
|
+
|
|
136
|
+
# Get all fixture names for signature modification
|
|
137
|
+
all_fixture_names = [ldr.fixture_name for ldr in func.__pytools_fixtures__]
|
|
138
|
+
|
|
139
|
+
# Check if the function is async
|
|
140
|
+
if asyncio.iscoroutinefunction(func):
|
|
141
|
+
|
|
142
|
+
@functools.wraps(func)
|
|
143
|
+
async def async_wrapper(*args, **kwargs):
|
|
144
|
+
request = kwargs.pop("request", None)
|
|
145
|
+
if request is None:
|
|
146
|
+
request = kwargs.pop("_pytools_request", None)
|
|
147
|
+
if getattr(func, "__pytools_wrapper__", False) and request is not None:
|
|
148
|
+
kwargs["_pytools_request"] = request
|
|
149
|
+
|
|
150
|
+
# Load all fixtures (sync operation)
|
|
151
|
+
for fixture_loader in async_wrapper.__pytools_fixtures__:
|
|
152
|
+
kwargs[fixture_loader.fixture_name] = fixture_loader.load()
|
|
153
|
+
return await func(*args, **kwargs)
|
|
154
|
+
|
|
155
|
+
async_wrapper.__pytools_fixtures__ = func.__pytools_fixtures__
|
|
156
|
+
async_wrapper.__pytools_test_file__ = test_file_path
|
|
157
|
+
async_wrapper.__pytools_wrapper__ = True # type: ignore
|
|
158
|
+
|
|
159
|
+
# Modify signature to exclude fixture parameters
|
|
160
|
+
base_sig = _get_base_signature(func)
|
|
161
|
+
async_wrapper.__signature__ = _create_new_signature(
|
|
162
|
+
base_sig, all_fixture_names
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return async_wrapper
|
|
166
|
+
else:
|
|
167
|
+
|
|
168
|
+
@functools.wraps(func)
|
|
169
|
+
def sync_wrapper(*args, **kwargs):
|
|
170
|
+
request = kwargs.pop("request", None)
|
|
171
|
+
if request is None:
|
|
172
|
+
request = kwargs.pop("_pytools_request", None)
|
|
173
|
+
if getattr(func, "__pytools_wrapper__", False) and request is not None:
|
|
174
|
+
kwargs["_pytools_request"] = request
|
|
175
|
+
|
|
176
|
+
# Load all fixtures
|
|
177
|
+
for fixture_loader in sync_wrapper.__pytools_fixtures__:
|
|
178
|
+
kwargs[fixture_loader.fixture_name] = fixture_loader.load()
|
|
179
|
+
return func(*args, **kwargs)
|
|
180
|
+
|
|
181
|
+
sync_wrapper.__pytools_fixtures__ = func.__pytools_fixtures__
|
|
182
|
+
sync_wrapper.__pytools_test_file__ = test_file_path
|
|
183
|
+
sync_wrapper.__pytools_wrapper__ = True # type: ignore
|
|
184
|
+
|
|
185
|
+
# Modify signature to exclude fixture parameters
|
|
186
|
+
base_sig = _get_base_signature(func)
|
|
187
|
+
sync_wrapper.__signature__ = _create_new_signature(
|
|
188
|
+
base_sig, all_fixture_names
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return sync_wrapper
|
|
192
|
+
|
|
193
|
+
return decorator
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Fixture loading logic for reading JSON files and mapping to objects."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Optional, Type, TypeVar, Union, List
|
|
5
|
+
|
|
6
|
+
from fixturify._utils._constants import ENCODING
|
|
7
|
+
from fixturify._utils._path_resolver import _PathResolver
|
|
8
|
+
from fixturify.object_mapper import ObjectMapper
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _FixtureLoader:
|
|
14
|
+
"""
|
|
15
|
+
Loads JSON fixtures and optionally maps them to objects.
|
|
16
|
+
|
|
17
|
+
Handles file reading, JSON parsing, and object conversion.
|
|
18
|
+
Each call to load() reads the file fresh (no caching).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
path: str,
|
|
24
|
+
fixture_name: str,
|
|
25
|
+
object_class: Optional[Type[T]],
|
|
26
|
+
test_file_path: str,
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Initialize the fixture loader.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
path: Relative path to the JSON file
|
|
33
|
+
fixture_name: Name of the parameter to inject
|
|
34
|
+
object_class: Optional class to deserialize JSON into
|
|
35
|
+
test_file_path: Absolute path to the test file (for path resolution)
|
|
36
|
+
"""
|
|
37
|
+
self._path = path
|
|
38
|
+
self._fixture_name = fixture_name
|
|
39
|
+
self._object_class = object_class
|
|
40
|
+
self._test_file_path = test_file_path
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def fixture_name(self) -> str:
|
|
44
|
+
"""Return the fixture name for parameter injection."""
|
|
45
|
+
return self._fixture_name
|
|
46
|
+
|
|
47
|
+
def load(self) -> Union[T, List[T], dict, list]:
|
|
48
|
+
"""
|
|
49
|
+
Load and optionally map the fixture.
|
|
50
|
+
|
|
51
|
+
Each call reads the file fresh from disk.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The loaded fixture data, either as raw dict/list or mapped object(s)
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
FileNotFoundError: If the JSON file doesn't exist
|
|
58
|
+
ValueError: If JSON parsing or object mapping fails
|
|
59
|
+
"""
|
|
60
|
+
# Resolve the path relative to the test file
|
|
61
|
+
try:
|
|
62
|
+
resolved_path = _PathResolver.resolve(self._path, self._test_file_path)
|
|
63
|
+
except FileNotFoundError as e:
|
|
64
|
+
raise FileNotFoundError(
|
|
65
|
+
f"Fixture file not found: {self._path} "
|
|
66
|
+
f"(resolved from test file: {self._test_file_path})"
|
|
67
|
+
) from e
|
|
68
|
+
|
|
69
|
+
# Read and parse the JSON file
|
|
70
|
+
try:
|
|
71
|
+
content = resolved_path.read_text(encoding=ENCODING)
|
|
72
|
+
data = json.loads(content)
|
|
73
|
+
except json.JSONDecodeError as e:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Invalid JSON in fixture file {resolved_path}: {e}"
|
|
76
|
+
) from e
|
|
77
|
+
|
|
78
|
+
# If no object_class specified, return raw dict/list
|
|
79
|
+
if self._object_class is None:
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
# Map to object using ObjectMapper
|
|
83
|
+
try:
|
|
84
|
+
return ObjectMapper(data).to_object(self._object_class)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Failed to convert fixture to {self._object_class.__name__}: {e}"
|
|
88
|
+
) from e
|