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,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,10 @@
1
+ """Phase enum for SQL execution timing."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class Phase(Enum):
7
+ """Execution phase for SQL scripts."""
8
+
9
+ BEFORE = "before" # Execute before test (default)
10
+ AFTER = "after" # Execute after test
@@ -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
+ ]