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,30 @@
|
|
|
1
|
+
"""Database connection configuration."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SqlTestConfig:
|
|
9
|
+
"""
|
|
10
|
+
Database connection configuration.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
driver: Database driver name (e.g., "psycopg2", "psycopg", "asyncpg", "mysql.connector", "aiomysql")
|
|
14
|
+
host: Database host address
|
|
15
|
+
database: Database name
|
|
16
|
+
user: Database user
|
|
17
|
+
password: Database password
|
|
18
|
+
port: Database port (optional, uses driver's default if not specified)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
driver: str
|
|
22
|
+
host: str
|
|
23
|
+
database: str
|
|
24
|
+
user: str
|
|
25
|
+
password: str
|
|
26
|
+
port: Optional[int] = None
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if self.driver:
|
|
30
|
+
self.driver = self.driver.lstrip("+").strip()
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""SQL decorator for executing SQL files before/after tests."""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import asyncio
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
8
|
+
from typing import Any, Callable, List, Optional, Tuple, TypeVar
|
|
9
|
+
|
|
10
|
+
from fixturify._utils import ENCODING, _PathResolver
|
|
11
|
+
|
|
12
|
+
from fixturify.sql_d._config import SqlTestConfig
|
|
13
|
+
from fixturify.sql_d._executor import SqlExecutor
|
|
14
|
+
from fixturify.sql_d._fixture_discovery import (
|
|
15
|
+
_find_config_from_request,
|
|
16
|
+
_is_pytest_available,
|
|
17
|
+
)
|
|
18
|
+
from fixturify.sql_d._phase import Phase
|
|
19
|
+
|
|
20
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _unwrap_func(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
24
|
+
"""Return the original function by walking the __wrapped__ chain."""
|
|
25
|
+
original = func
|
|
26
|
+
while hasattr(original, "__wrapped__"):
|
|
27
|
+
original = original.__wrapped__
|
|
28
|
+
return original
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_test_file_path(func: Callable[..., Any]) -> str:
|
|
32
|
+
"""Get the test file path from a wrapped function."""
|
|
33
|
+
if hasattr(func, "__pytools_test_file__"):
|
|
34
|
+
return getattr(func, "__pytools_test_file__")
|
|
35
|
+
return inspect.getfile(_unwrap_func(func))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _unwrap_sql_wrappers(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
39
|
+
"""Skip stacked SQL wrappers to avoid double execution."""
|
|
40
|
+
current = func
|
|
41
|
+
while hasattr(current, "__pytools_sql_wrapper__"):
|
|
42
|
+
current = current.__wrapped__ # type: ignore[attr-defined]
|
|
43
|
+
return current
|
|
44
|
+
|
|
45
|
+
# Thread pool for running sync executors in async context
|
|
46
|
+
_THREAD_POOL = ThreadPoolExecutor(max_workers=4)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cleanup_thread_pool() -> None:
|
|
50
|
+
"""Cleanup thread pool on interpreter shutdown."""
|
|
51
|
+
_THREAD_POOL.shutdown(wait=False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Register cleanup handler
|
|
55
|
+
atexit.register(_cleanup_thread_pool)
|
|
56
|
+
|
|
57
|
+
# Attribute name for storing SQL decorator metadata on test functions
|
|
58
|
+
_SQL_DECORATORS_ATTR = "_sql_decorators"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sql(
|
|
62
|
+
path: str,
|
|
63
|
+
phase: Phase = Phase.BEFORE,
|
|
64
|
+
config: Optional[SqlTestConfig] = None,
|
|
65
|
+
) -> Callable[[F], F]:
|
|
66
|
+
"""
|
|
67
|
+
Decorator to execute SQL file before or after test execution.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
path: Relative path to SQL file (resolved from test file location)
|
|
71
|
+
phase: When to execute - BEFORE (default) or AFTER the test
|
|
72
|
+
config: Database configuration. If not provided, looks for pytest
|
|
73
|
+
fixture returning SqlTestConfig type.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Decorated test function
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
FileNotFoundError: If SQL file doesn't exist
|
|
80
|
+
ValueError: If config not provided and no pytest fixture found
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
@sql(path="./setup.sql")
|
|
84
|
+
def test_something():
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
@sql(path="./setup.sql", phase=Phase.BEFORE)
|
|
88
|
+
@sql(path="./cleanup.sql", phase=Phase.AFTER)
|
|
89
|
+
def test_with_cleanup():
|
|
90
|
+
pass
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def decorator(func: F) -> F:
|
|
94
|
+
# Store decorator info for chaining support
|
|
95
|
+
decorators: List[Tuple[str, Phase, Optional[SqlTestConfig]]] = getattr(
|
|
96
|
+
func, _SQL_DECORATORS_ATTR, []
|
|
97
|
+
).copy()
|
|
98
|
+
decorators.append((path, phase, config))
|
|
99
|
+
|
|
100
|
+
# Get the original function if already wrapped
|
|
101
|
+
original_func = _unwrap_func(func)
|
|
102
|
+
is_async = asyncio.iscoroutinefunction(func)
|
|
103
|
+
|
|
104
|
+
# Check if any decorator needs fixture discovery (config is None)
|
|
105
|
+
needs_fixture_discovery = any(cfg is None for _, _, cfg in decorators)
|
|
106
|
+
|
|
107
|
+
if is_async:
|
|
108
|
+
wrapper = _create_async_wrapper(func, decorators, needs_fixture_discovery)
|
|
109
|
+
else:
|
|
110
|
+
wrapper = _create_sync_wrapper(func, decorators, needs_fixture_discovery)
|
|
111
|
+
|
|
112
|
+
# Preserve function metadata
|
|
113
|
+
functools.update_wrapper(wrapper, func)
|
|
114
|
+
wrapper._original_func = original_func # type: ignore
|
|
115
|
+
wrapper._sql_decorators = decorators # type: ignore
|
|
116
|
+
wrapper.__pytools_test_file__ = _get_test_file_path(func) # type: ignore
|
|
117
|
+
wrapper.__pytools_wrapper__ = True # type: ignore
|
|
118
|
+
wrapper.__pytools_sql_wrapper__ = True # type: ignore
|
|
119
|
+
|
|
120
|
+
# If we need fixture discovery, add 'request' to signature
|
|
121
|
+
if needs_fixture_discovery and _is_pytest_available():
|
|
122
|
+
wrapper = _add_request_parameter(wrapper, func)
|
|
123
|
+
|
|
124
|
+
return wrapper # type: ignore
|
|
125
|
+
|
|
126
|
+
return decorator
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _add_request_parameter(
|
|
130
|
+
wrapper: Callable[..., Any], original_func: Callable[..., Any]
|
|
131
|
+
) -> Callable[..., Any]:
|
|
132
|
+
"""
|
|
133
|
+
Add 'request' parameter to wrapper's signature for pytest fixture injection.
|
|
134
|
+
|
|
135
|
+
Pytest inspects function signatures to determine which fixtures to inject.
|
|
136
|
+
By adding 'request' to the signature, pytest will inject the FixtureRequest
|
|
137
|
+
object, which we can use to discover SqlTestConfig fixtures.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
wrapper: The wrapped function
|
|
141
|
+
original_func: The original test function
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Wrapper with modified signature
|
|
145
|
+
"""
|
|
146
|
+
if hasattr(wrapper, "__signature__"):
|
|
147
|
+
sig = wrapper.__signature__ # type: ignore
|
|
148
|
+
else:
|
|
149
|
+
sig = inspect.signature(original_func)
|
|
150
|
+
|
|
151
|
+
# Check if 'request' is already in the signature
|
|
152
|
+
if "request" in sig.parameters:
|
|
153
|
+
return wrapper
|
|
154
|
+
|
|
155
|
+
# Create new signature with 'request' added as keyword-only parameter
|
|
156
|
+
new_params = list(sig.parameters.values())
|
|
157
|
+
request_param = inspect.Parameter(
|
|
158
|
+
"request",
|
|
159
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
160
|
+
)
|
|
161
|
+
new_params.append(request_param)
|
|
162
|
+
new_sig = sig.replace(parameters=new_params)
|
|
163
|
+
|
|
164
|
+
# Set the new signature on the wrapper
|
|
165
|
+
wrapper.__signature__ = new_sig # type: ignore
|
|
166
|
+
|
|
167
|
+
return wrapper
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _create_sync_wrapper(
|
|
171
|
+
func: Callable[..., Any],
|
|
172
|
+
decorators: List[Tuple[str, Phase, Optional[SqlTestConfig]]],
|
|
173
|
+
needs_fixture_discovery: bool,
|
|
174
|
+
) -> Callable[..., Any]:
|
|
175
|
+
"""Create wrapper for sync test functions."""
|
|
176
|
+
func_to_call = _unwrap_sql_wrappers(func)
|
|
177
|
+
|
|
178
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
179
|
+
# Extract and propagate request for stacked decorators
|
|
180
|
+
request = kwargs.pop("request", None)
|
|
181
|
+
if request is None:
|
|
182
|
+
request = kwargs.pop("_pytools_request", None)
|
|
183
|
+
|
|
184
|
+
if request is not None and getattr(func_to_call, "__pytools_wrapper__", False):
|
|
185
|
+
kwargs["_pytools_request"] = request
|
|
186
|
+
|
|
187
|
+
# Resolve all configs and paths
|
|
188
|
+
before_tasks, after_tasks = _prepare_sql_tasks(func, decorators, request)
|
|
189
|
+
|
|
190
|
+
# Track if any BEFORE script ran (for cleanup decision)
|
|
191
|
+
before_started = False
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
# Execute BEFORE phase (top to bottom)
|
|
195
|
+
# Note: Python evaluates decorators bottom-to-top, so list is reversed
|
|
196
|
+
for sql_content, resolved_config in reversed(before_tasks):
|
|
197
|
+
before_started = True
|
|
198
|
+
_execute_sql_sync(sql_content, resolved_config)
|
|
199
|
+
|
|
200
|
+
# Run the actual test
|
|
201
|
+
if not getattr(func_to_call, "__pytools_wrapper__", False):
|
|
202
|
+
kwargs.pop("_pytools_request", None)
|
|
203
|
+
result = func_to_call(*args, **kwargs)
|
|
204
|
+
return result
|
|
205
|
+
finally:
|
|
206
|
+
# Execute AFTER phase if any BEFORE ran or test ran
|
|
207
|
+
# This ensures cleanup runs even if BEFORE fails partway through
|
|
208
|
+
if before_started or not before_tasks:
|
|
209
|
+
for sql_content, resolved_config in after_tasks:
|
|
210
|
+
_execute_sql_sync(sql_content, resolved_config)
|
|
211
|
+
|
|
212
|
+
return wrapper
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _create_async_wrapper(
|
|
216
|
+
func: Callable[..., Any],
|
|
217
|
+
decorators: List[Tuple[str, Phase, Optional[SqlTestConfig]]],
|
|
218
|
+
needs_fixture_discovery: bool,
|
|
219
|
+
) -> Callable[..., Any]:
|
|
220
|
+
"""Create wrapper for async test functions."""
|
|
221
|
+
func_to_call = _unwrap_sql_wrappers(func)
|
|
222
|
+
|
|
223
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
224
|
+
# Extract and propagate request for stacked decorators
|
|
225
|
+
request = kwargs.pop("request", None)
|
|
226
|
+
if request is None:
|
|
227
|
+
request = kwargs.pop("_pytools_request", None)
|
|
228
|
+
|
|
229
|
+
if request is not None and getattr(func_to_call, "__pytools_wrapper__", False):
|
|
230
|
+
kwargs["_pytools_request"] = request
|
|
231
|
+
|
|
232
|
+
# Resolve all configs and paths
|
|
233
|
+
before_tasks, after_tasks = _prepare_sql_tasks(func, decorators, request)
|
|
234
|
+
|
|
235
|
+
# Track if any BEFORE script ran (for cleanup decision)
|
|
236
|
+
before_started = False
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
# Execute BEFORE phase (top to bottom)
|
|
240
|
+
# Note: Python evaluates decorators bottom-to-top, so list is reversed
|
|
241
|
+
for sql_content, resolved_config in reversed(before_tasks):
|
|
242
|
+
before_started = True
|
|
243
|
+
await _execute_sql_async(sql_content, resolved_config)
|
|
244
|
+
|
|
245
|
+
# Run the actual test
|
|
246
|
+
if not getattr(func_to_call, "__pytools_wrapper__", False):
|
|
247
|
+
kwargs.pop("_pytools_request", None)
|
|
248
|
+
result = await func_to_call(*args, **kwargs)
|
|
249
|
+
return result
|
|
250
|
+
finally:
|
|
251
|
+
# Execute AFTER phase if any BEFORE ran or test ran
|
|
252
|
+
# This ensures cleanup runs even if BEFORE fails partway through
|
|
253
|
+
if before_started or not before_tasks:
|
|
254
|
+
for sql_content, resolved_config in after_tasks:
|
|
255
|
+
await _execute_sql_async(sql_content, resolved_config)
|
|
256
|
+
|
|
257
|
+
return wrapper
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _prepare_sql_tasks(
|
|
261
|
+
func: Callable[..., Any],
|
|
262
|
+
decorators: List[Tuple[str, Phase, Optional[SqlTestConfig]]],
|
|
263
|
+
request: Any = None,
|
|
264
|
+
) -> Tuple[List[Tuple[str, SqlTestConfig]], List[Tuple[str, SqlTestConfig]]]:
|
|
265
|
+
"""
|
|
266
|
+
Prepare SQL tasks by resolving paths and configs.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
func: The test function
|
|
270
|
+
decorators: List of (path, phase, config) tuples
|
|
271
|
+
request: Pytest FixtureRequest object (if available)
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Tuple of (before_tasks, after_tasks) where each task is (sql_content, config)
|
|
275
|
+
"""
|
|
276
|
+
before_tasks: List[Tuple[str, SqlTestConfig]] = []
|
|
277
|
+
after_tasks: List[Tuple[str, SqlTestConfig]] = []
|
|
278
|
+
|
|
279
|
+
# Get the original test file for path resolution
|
|
280
|
+
test_file = _get_test_file_path(func)
|
|
281
|
+
|
|
282
|
+
for path, phase, config in decorators:
|
|
283
|
+
# Resolve config
|
|
284
|
+
resolved_config = _resolve_config(config, request)
|
|
285
|
+
|
|
286
|
+
# Resolve path and read SQL content
|
|
287
|
+
sql_path = _PathResolver.resolve(path, reference_file=test_file)
|
|
288
|
+
with open(sql_path, "r", encoding=ENCODING) as f:
|
|
289
|
+
sql_content = f.read()
|
|
290
|
+
|
|
291
|
+
if phase == Phase.BEFORE:
|
|
292
|
+
before_tasks.append((sql_content, resolved_config))
|
|
293
|
+
else:
|
|
294
|
+
after_tasks.append((sql_content, resolved_config))
|
|
295
|
+
|
|
296
|
+
return before_tasks, after_tasks
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _resolve_config(
|
|
300
|
+
config: Optional[SqlTestConfig],
|
|
301
|
+
request: Any = None,
|
|
302
|
+
) -> SqlTestConfig:
|
|
303
|
+
"""
|
|
304
|
+
Resolve database configuration.
|
|
305
|
+
|
|
306
|
+
Priority:
|
|
307
|
+
1. Direct config parameter
|
|
308
|
+
2. Pytest fixture discovery via request
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
config: Direct config or None
|
|
312
|
+
request: Pytest FixtureRequest object (if available)
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Resolved SqlTestConfig
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
ValueError: If no config found
|
|
319
|
+
"""
|
|
320
|
+
if config is not None:
|
|
321
|
+
return config
|
|
322
|
+
|
|
323
|
+
# Try fixture discovery via request
|
|
324
|
+
if request is not None:
|
|
325
|
+
discovered_config = _find_config_from_request(request)
|
|
326
|
+
if discovered_config is not None:
|
|
327
|
+
return discovered_config
|
|
328
|
+
|
|
329
|
+
raise ValueError(
|
|
330
|
+
"No database configuration provided. Either pass 'config' parameter "
|
|
331
|
+
"to @sql decorator or define a pytest fixture returning SqlTestConfig."
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _execute_sql_sync(sql_content: str, config: SqlTestConfig) -> None:
|
|
336
|
+
"""
|
|
337
|
+
Execute SQL synchronously.
|
|
338
|
+
|
|
339
|
+
Handles both sync and async drivers from sync context.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
sql_content: SQL content to execute
|
|
343
|
+
config: Database configuration
|
|
344
|
+
"""
|
|
345
|
+
executor = SqlExecutor(config)
|
|
346
|
+
|
|
347
|
+
if executor.is_async:
|
|
348
|
+
# Async driver from sync context - use asyncio.run()
|
|
349
|
+
asyncio.run(executor.execute_async(sql_content))
|
|
350
|
+
else:
|
|
351
|
+
# Sync driver - direct call
|
|
352
|
+
executor.execute(sql_content)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def _execute_sql_async(sql_content: str, config: SqlTestConfig) -> None:
|
|
356
|
+
"""
|
|
357
|
+
Execute SQL asynchronously.
|
|
358
|
+
|
|
359
|
+
Handles both sync and async drivers from async context.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
sql_content: SQL content to execute
|
|
363
|
+
config: Database configuration
|
|
364
|
+
"""
|
|
365
|
+
executor = SqlExecutor(config)
|
|
366
|
+
|
|
367
|
+
if executor.is_async:
|
|
368
|
+
# Async driver - direct await
|
|
369
|
+
await executor.execute_async(sql_content)
|
|
370
|
+
else:
|
|
371
|
+
# Sync driver from async context - run in thread pool
|
|
372
|
+
loop = asyncio.get_running_loop()
|
|
373
|
+
await loop.run_in_executor(_THREAD_POOL, executor.execute, sql_content)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Driver configuration registry for database drivers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Dict, FrozenSet
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class _DriverConfig:
|
|
9
|
+
"""Configuration for a database driver."""
|
|
10
|
+
|
|
11
|
+
module_name: str
|
|
12
|
+
is_async: bool
|
|
13
|
+
param_mapping: Dict[str, str] # SqlTestConfig field -> driver parameter name
|
|
14
|
+
default_port: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Known async drivers
|
|
18
|
+
_ASYNC_DRIVERS: FrozenSet[str] = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"asyncpg",
|
|
21
|
+
"aiomysql",
|
|
22
|
+
"aiosqlite",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Driver registry with connection parameter mappings
|
|
27
|
+
_DRIVER_REGISTRY: Dict[str, _DriverConfig] = {
|
|
28
|
+
# PostgreSQL - sync
|
|
29
|
+
"psycopg2": _DriverConfig(
|
|
30
|
+
module_name="psycopg2",
|
|
31
|
+
is_async=False,
|
|
32
|
+
param_mapping={
|
|
33
|
+
"host": "host",
|
|
34
|
+
"database": "dbname", # psycopg2 uses 'dbname'
|
|
35
|
+
"user": "user",
|
|
36
|
+
"password": "password",
|
|
37
|
+
"port": "port",
|
|
38
|
+
},
|
|
39
|
+
default_port=5432,
|
|
40
|
+
),
|
|
41
|
+
# PostgreSQL - async
|
|
42
|
+
"asyncpg": _DriverConfig(
|
|
43
|
+
module_name="asyncpg",
|
|
44
|
+
is_async=True,
|
|
45
|
+
param_mapping={
|
|
46
|
+
"host": "host",
|
|
47
|
+
"database": "database",
|
|
48
|
+
"user": "user",
|
|
49
|
+
"password": "password",
|
|
50
|
+
"port": "port",
|
|
51
|
+
},
|
|
52
|
+
default_port=5432,
|
|
53
|
+
),
|
|
54
|
+
# MySQL - sync
|
|
55
|
+
"mysql.connector": _DriverConfig(
|
|
56
|
+
module_name="mysql.connector",
|
|
57
|
+
is_async=False,
|
|
58
|
+
param_mapping={
|
|
59
|
+
"host": "host",
|
|
60
|
+
"database": "database",
|
|
61
|
+
"user": "user",
|
|
62
|
+
"password": "password",
|
|
63
|
+
"port": "port",
|
|
64
|
+
},
|
|
65
|
+
default_port=3306,
|
|
66
|
+
),
|
|
67
|
+
# MySQL - async
|
|
68
|
+
"aiomysql": _DriverConfig(
|
|
69
|
+
module_name="aiomysql",
|
|
70
|
+
is_async=True,
|
|
71
|
+
param_mapping={
|
|
72
|
+
"host": "host",
|
|
73
|
+
"database": "db", # aiomysql uses 'db'
|
|
74
|
+
"user": "user",
|
|
75
|
+
"password": "password",
|
|
76
|
+
"port": "port",
|
|
77
|
+
},
|
|
78
|
+
default_port=3306,
|
|
79
|
+
),
|
|
80
|
+
# SQLite - sync (for completeness)
|
|
81
|
+
"sqlite3": _DriverConfig(
|
|
82
|
+
module_name="sqlite3",
|
|
83
|
+
is_async=False,
|
|
84
|
+
param_mapping={
|
|
85
|
+
"database": "database", # sqlite3 only needs database (file path)
|
|
86
|
+
},
|
|
87
|
+
default_port=0, # Not applicable for SQLite
|
|
88
|
+
),
|
|
89
|
+
# SQLite - async
|
|
90
|
+
"aiosqlite": _DriverConfig(
|
|
91
|
+
module_name="aiosqlite",
|
|
92
|
+
is_async=True,
|
|
93
|
+
param_mapping={
|
|
94
|
+
"database": "database",
|
|
95
|
+
},
|
|
96
|
+
default_port=0,
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _get_driver_config(driver_name: str) -> _DriverConfig:
|
|
102
|
+
"""
|
|
103
|
+
Get driver configuration by name.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
driver_name: Name of the database driver
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Driver configuration
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ValueError: If driver is not supported
|
|
113
|
+
"""
|
|
114
|
+
if driver_name not in _DRIVER_REGISTRY:
|
|
115
|
+
supported = ", ".join(sorted(_DRIVER_REGISTRY.keys()))
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Unsupported driver: '{driver_name}'. Supported drivers: {supported}"
|
|
118
|
+
)
|
|
119
|
+
return _DRIVER_REGISTRY[driver_name]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _is_async_driver(driver_name: str) -> bool:
|
|
123
|
+
"""
|
|
124
|
+
Check if a driver is async.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
driver_name: Name of the database driver
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True if the driver is async, False otherwise
|
|
131
|
+
"""
|
|
132
|
+
config = _get_driver_config(driver_name)
|
|
133
|
+
return config.is_async
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""SQL execution using Strategy pattern (v2).
|
|
2
|
+
|
|
3
|
+
This is a cleaner implementation that delegates driver-specific
|
|
4
|
+
logic to individual strategy classes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from ._config import SqlTestConfig
|
|
9
|
+
from ._strategies import get_strategy_registry, AsyncSqlExecutionStrategy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SqlExecutor:
|
|
13
|
+
"""
|
|
14
|
+
Unified SQL executor using Strategy pattern.
|
|
15
|
+
|
|
16
|
+
Automatically selects the appropriate strategy based on the driver
|
|
17
|
+
and handles both sync and async execution.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: SqlTestConfig):
|
|
21
|
+
"""
|
|
22
|
+
Initialize the executor.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
config: Database connection configuration
|
|
26
|
+
"""
|
|
27
|
+
self._config = config
|
|
28
|
+
self._registry = get_strategy_registry()
|
|
29
|
+
self._strategy = self._registry.get_or_raise(config.driver)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_async(self) -> bool:
|
|
33
|
+
"""Check if this executor uses async execution."""
|
|
34
|
+
return self._strategy.is_async
|
|
35
|
+
|
|
36
|
+
def execute(self, sql_content: str) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Execute SQL content synchronously.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
sql_content: SQL statements to execute
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
RuntimeError: If strategy is async
|
|
45
|
+
"""
|
|
46
|
+
if self._strategy.is_async:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
f"Driver '{self._config.driver}' is async. "
|
|
49
|
+
f"Use execute_async() instead."
|
|
50
|
+
)
|
|
51
|
+
self._strategy.execute(sql_content, self._config)
|
|
52
|
+
|
|
53
|
+
async def execute_async(self, sql_content: str) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Execute SQL content asynchronously.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
sql_content: SQL statements to execute
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RuntimeError: If strategy is sync
|
|
62
|
+
"""
|
|
63
|
+
if not self._strategy.is_async:
|
|
64
|
+
raise RuntimeError(
|
|
65
|
+
f"Driver '{self._config.driver}' is sync. "
|
|
66
|
+
f"Use execute() instead."
|
|
67
|
+
)
|
|
68
|
+
strategy: AsyncSqlExecutionStrategy = self._strategy
|
|
69
|
+
await strategy.execute_async(sql_content, self._config)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_executor(config: SqlTestConfig) -> SqlExecutor:
|
|
73
|
+
"""
|
|
74
|
+
Create an executor for the given configuration.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
config: Database connection configuration
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
SqlExecutor instance
|
|
81
|
+
"""
|
|
82
|
+
return SqlExecutor(config)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Pytest fixture discovery for SqlTestConfig."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
|
4
|
+
|
|
5
|
+
from fixturify._utils._fixture_discovery import (
|
|
6
|
+
find_fixture_by_type,
|
|
7
|
+
is_pytest_available as _is_pytest_available,
|
|
8
|
+
)
|
|
9
|
+
from fixturify.sql_d._config import SqlTestConfig
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from _pytest.fixtures import FixtureRequest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _find_config_from_request(request: "FixtureRequest") -> Optional[SqlTestConfig]:
|
|
16
|
+
"""
|
|
17
|
+
Find SqlTestConfig fixture using pytest's request object.
|
|
18
|
+
|
|
19
|
+
Scans ALL available fixtures (not just function dependencies) for fixtures
|
|
20
|
+
that return SqlTestConfig. This allows defining the config fixture in
|
|
21
|
+
conftest.py without needing to add it as a test parameter.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
request: Pytest's FixtureRequest object
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
SqlTestConfig if a matching fixture is found, None otherwise
|
|
28
|
+
"""
|
|
29
|
+
return find_fixture_by_type(request, SqlTestConfig)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Re-export for backward compatibility
|
|
33
|
+
_is_pytest_available = _is_pytest_available
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _FixtureDiscovery:
|
|
37
|
+
"""Discover pytest fixtures by return type using request parameter."""
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def find_config_from_request(request: "FixtureRequest") -> Optional[SqlTestConfig]:
|
|
41
|
+
"""
|
|
42
|
+
Find SqlTestConfig fixture using pytest's request.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
request: Pytest's FixtureRequest object
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
SqlTestConfig from fixture if found, None otherwise
|
|
49
|
+
"""
|
|
50
|
+
return _find_config_from_request(request)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def is_pytest_available() -> bool:
|
|
54
|
+
"""Check if pytest is installed."""
|
|
55
|
+
return _is_pytest_available()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""SQL execution strategies for different database drivers."""
|
|
2
|
+
|
|
3
|
+
from ._base import SqlExecutionStrategy, AsyncSqlExecutionStrategy
|
|
4
|
+
from ._registry import StrategyRegistry, get_strategy_registry
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"SqlExecutionStrategy",
|
|
8
|
+
"AsyncSqlExecutionStrategy",
|
|
9
|
+
"StrategyRegistry",
|
|
10
|
+
"get_strategy_registry",
|
|
11
|
+
]
|