sparkless-testing 0.1.0__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.
@@ -0,0 +1,87 @@
1
+ """
2
+ sparkless-testing - Easy testing with sparkless or PySpark.
3
+
4
+ A Python package that simplifies running tests with either sparkless (mock)
5
+ or PySpark (real) engines on demand.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __author__ = "Odos Matthews"
10
+ __email__ = "odosmatthews@gmail.com"
11
+
12
+ # Core engine configuration
13
+ from .engine import (
14
+ EngineConfig,
15
+ EngineType,
16
+ auto_configure_engine,
17
+ configure_engine,
18
+ detect_available_engines,
19
+ get_engine,
20
+ reset_engine_state,
21
+ )
22
+
23
+ # Protocols (for type checking)
24
+ from .protocols import (
25
+ AnalysisExceptionProtocol,
26
+ ColumnProtocol,
27
+ DataFrameProtocol,
28
+ FunctionsProtocol,
29
+ SparkSessionProtocol,
30
+ TypesProtocol,
31
+ WindowProtocol,
32
+ )
33
+
34
+ # Session factory
35
+ from .session import (
36
+ SessionConfig,
37
+ create_mock_session,
38
+ create_pyspark_session,
39
+ create_session,
40
+ stop_all_sessions,
41
+ )
42
+
43
+ # Test utilities
44
+ from .utils import (
45
+ create_test_dataframe,
46
+ detect_spark_type,
47
+ get_app_name_prefix,
48
+ get_spark_mode,
49
+ get_warehouse_dir,
50
+ is_dataframe_like,
51
+ )
52
+
53
+ __all__ = [
54
+ # Package metadata
55
+ "__version__",
56
+ "__author__",
57
+ "__email__",
58
+ # Engine configuration
59
+ "EngineType",
60
+ "EngineConfig",
61
+ "configure_engine",
62
+ "get_engine",
63
+ "reset_engine_state",
64
+ "detect_available_engines",
65
+ "auto_configure_engine",
66
+ # Session factory
67
+ "SessionConfig",
68
+ "create_mock_session",
69
+ "create_pyspark_session",
70
+ "create_session",
71
+ "stop_all_sessions",
72
+ # Test utilities
73
+ "detect_spark_type",
74
+ "is_dataframe_like",
75
+ "create_test_dataframe",
76
+ "get_spark_mode",
77
+ "get_warehouse_dir",
78
+ "get_app_name_prefix",
79
+ # Protocols
80
+ "ColumnProtocol",
81
+ "DataFrameProtocol",
82
+ "FunctionsProtocol",
83
+ "TypesProtocol",
84
+ "WindowProtocol",
85
+ "AnalysisExceptionProtocol",
86
+ "SparkSessionProtocol",
87
+ ]
@@ -0,0 +1,39 @@
1
+ """
2
+ Auto-configure pytest fixtures for sparkless-testing.
3
+
4
+ This module automatically registers pytest fixtures and hooks when imported.
5
+ Import this in your conftest.py or test files to enable sparkless-testing fixtures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Import parametrization hooks
11
+ from .parametrize import ( # noqa: F401
12
+ both_engines,
13
+ parametrize_engines,
14
+ pytest_configure,
15
+ pytest_generate_tests,
16
+ )
17
+
18
+ # Import fixtures to register them
19
+ from .pytest_fixtures import ( # noqa: F401
20
+ mock_spark_session,
21
+ pyspark_session,
22
+ spark_engine_type,
23
+ spark_functions,
24
+ spark_session,
25
+ spark_types,
26
+ )
27
+
28
+ __all__ = [
29
+ "spark_session",
30
+ "mock_spark_session",
31
+ "pyspark_session",
32
+ "spark_functions",
33
+ "spark_types",
34
+ "spark_engine_type",
35
+ "both_engines",
36
+ "parametrize_engines",
37
+ "pytest_configure",
38
+ "pytest_generate_tests",
39
+ ]
@@ -0,0 +1,306 @@
1
+ """
2
+ Engine detection and configuration for sparkless-testing.
3
+
4
+ This module provides centralized engine detection and configuration,
5
+ allowing tests to work with either sparkless (mock) or PySpark (real) engines.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from typing import Any
14
+
15
+ from .protocols import (
16
+ AnalysisExceptionProtocol,
17
+ FunctionsProtocol,
18
+ TypesProtocol,
19
+ WindowProtocol,
20
+ )
21
+
22
+
23
+ class EngineType(Enum):
24
+ """Engine type enumeration."""
25
+
26
+ MOCK = "mock"
27
+ PYSPARK = "pyspark"
28
+ AUTO = "auto"
29
+
30
+
31
+ @dataclass
32
+ class EngineConfig:
33
+ """Configuration for a Spark engine."""
34
+
35
+ functions: FunctionsProtocol
36
+ types: TypesProtocol
37
+ analysis_exception: type[BaseException] | AnalysisExceptionProtocol
38
+ window: WindowProtocol | None = None
39
+ desc: Any | None = None
40
+ engine_name: str = "unknown"
41
+ engine_type: EngineType = EngineType.AUTO
42
+ dataframe_cls: Any | None = None
43
+ spark_session_cls: Any | None = None
44
+ column_cls: Any | None = None
45
+
46
+
47
+ # Global engine state (for backward compatibility)
48
+ _engine: EngineConfig | None = None
49
+
50
+ # Thread-local engine state (for parallel test isolation)
51
+ _thread_local = threading.local()
52
+
53
+
54
+ def configure_engine(
55
+ *,
56
+ functions: FunctionsProtocol,
57
+ types: TypesProtocol,
58
+ analysis_exception: type[BaseException] | AnalysisExceptionProtocol,
59
+ window: WindowProtocol | None = None,
60
+ desc: Any | None = None,
61
+ engine_name: str = "unknown",
62
+ engine_type: EngineType = EngineType.AUTO,
63
+ dataframe_cls: Any | None = None,
64
+ spark_session_cls: Any | None = None,
65
+ column_cls: Any | None = None,
66
+ ) -> None:
67
+ """
68
+ Inject engine components.
69
+
70
+ Sets both thread-local and global engine state for backward compatibility.
71
+ Thread-local state takes precedence in get_engine() for parallel test isolation.
72
+
73
+ **Thread-Local vs Global State:**
74
+
75
+ - **Thread-local state**: Used for parallel test execution (pytest-xdist).
76
+ Each thread maintains its own engine configuration, ensuring test isolation.
77
+ This is checked first by get_engine().
78
+
79
+ - **Global state**: Used for backward compatibility and single-threaded execution.
80
+ Falls back to this if thread-local state is not set.
81
+
82
+ When configure_engine() is called, it sets BOTH thread-local and global state.
83
+ This ensures compatibility with both parallel and sequential test execution.
84
+
85
+ Args:
86
+ functions: Functions module (F) from the engine
87
+ types: Types module from the engine
88
+ analysis_exception: AnalysisException class from the engine
89
+ window: Window class from the engine (optional)
90
+ desc: desc function from the engine (optional)
91
+ engine_name: Name of the engine (e.g., "mock", "pyspark")
92
+ engine_type: EngineType enum value
93
+ dataframe_cls: DataFrame class from the engine (optional)
94
+ spark_session_cls: SparkSession class from the engine (optional)
95
+ column_cls: Column class from the engine (optional)
96
+ """
97
+ global _engine
98
+ engine_config = EngineConfig(
99
+ functions=functions,
100
+ types=types,
101
+ analysis_exception=analysis_exception,
102
+ window=window,
103
+ desc=desc,
104
+ engine_name=engine_name,
105
+ engine_type=engine_type,
106
+ dataframe_cls=dataframe_cls,
107
+ spark_session_cls=spark_session_cls,
108
+ column_cls=column_cls,
109
+ )
110
+
111
+ # Set global state (for backward compatibility)
112
+ _engine = engine_config
113
+
114
+ # Set thread-local state (for parallel test isolation)
115
+ _thread_local.engine = engine_config
116
+
117
+
118
+ def get_engine() -> EngineConfig:
119
+ """
120
+ Get the current engine config, raising if not configured.
121
+
122
+ **Priority Order:**
123
+ 1. Thread-local state (checked first for parallel test isolation)
124
+ 2. Global state (fallback for backward compatibility)
125
+
126
+ This ensures that in parallel test execution, each thread uses its own
127
+ engine configuration, while single-threaded execution uses the global state.
128
+
129
+ Returns:
130
+ EngineConfig instance
131
+
132
+ Raises:
133
+ RuntimeError: If engine is not configured in either thread-local or global state
134
+ """
135
+ # Try thread-local first (for parallel test isolation)
136
+ if hasattr(_thread_local, "engine") and _thread_local.engine is not None:
137
+ return _thread_local.engine # type: ignore[no-any-return]
138
+
139
+ # Fallback to global state (for backward compatibility)
140
+ if _engine is None:
141
+ raise RuntimeError(
142
+ "Engine not configured. Call configure_engine(...) before using sparkless-testing."
143
+ )
144
+ return _engine
145
+
146
+
147
+ def reset_engine_state() -> None:
148
+ """
149
+ Reset thread-local engine state.
150
+
151
+ This is useful for test isolation - clears the thread-local engine
152
+ so the next get_engine() call will use global state or raise an error.
153
+
154
+ **Note:** This only resets thread-local state, not global state.
155
+ After calling this, get_engine() will fall back to global state if available,
156
+ or raise RuntimeError if global state is also not configured.
157
+ """
158
+ if hasattr(_thread_local, "engine"):
159
+ delattr(_thread_local, "engine")
160
+
161
+
162
+ def detect_available_engines() -> set[EngineType]:
163
+ """
164
+ Detect which engines are available (installed).
165
+
166
+ Returns:
167
+ Set of available EngineType values
168
+ """
169
+ available: set[EngineType] = set()
170
+
171
+ # Check for sparkless (mock)
172
+ try:
173
+ import sparkless # noqa: F401
174
+
175
+ available.add(EngineType.MOCK)
176
+ except ImportError:
177
+ pass
178
+
179
+ # Check for PySpark
180
+ try:
181
+ import pyspark # noqa: F401
182
+
183
+ available.add(EngineType.PYSPARK)
184
+ except ImportError:
185
+ pass
186
+
187
+ return available
188
+
189
+
190
+ def auto_configure_engine(
191
+ preferred_engine: EngineType | None = None,
192
+ ) -> EngineConfig:
193
+ """
194
+ Automatically configure engine based on availability and preference.
195
+
196
+ Args:
197
+ preferred_engine: Preferred engine type (defaults to MOCK if available)
198
+
199
+ Returns:
200
+ EngineConfig instance
201
+
202
+ Raises:
203
+ RuntimeError: If no engines are available
204
+ """
205
+ available = detect_available_engines()
206
+
207
+ if not available:
208
+ raise RuntimeError("No Spark engines available. Install either 'sparkless' or 'pyspark'.")
209
+
210
+ # Determine which engine to use
211
+ if preferred_engine is None:
212
+ # Default preference: MOCK > PYSPARK
213
+ if EngineType.MOCK in available:
214
+ preferred_engine = EngineType.MOCK
215
+ elif EngineType.PYSPARK in available:
216
+ preferred_engine = EngineType.PYSPARK
217
+ else:
218
+ # Use first available
219
+ preferred_engine = next(iter(available))
220
+
221
+ if preferred_engine not in available:
222
+ raise RuntimeError(
223
+ f"Preferred engine '{preferred_engine.value}' is not available. "
224
+ f"Available engines: {[e.value for e in available]}"
225
+ )
226
+
227
+ # Configure based on engine type
228
+ if preferred_engine == EngineType.MOCK:
229
+ return _configure_mock_engine()
230
+ elif preferred_engine == EngineType.PYSPARK:
231
+ return _configure_pyspark_engine()
232
+ else:
233
+ raise RuntimeError(f"Unknown engine type: {preferred_engine}")
234
+
235
+
236
+ def _configure_mock_engine() -> EngineConfig:
237
+ """Configure sparkless (mock) engine."""
238
+ try:
239
+ from sparkless import AnalysisException as MockAnalysisException
240
+ from sparkless import Column as MockColumn
241
+ from sparkless import DataFrame as MockDataFrame
242
+ from sparkless import SparkSession as MockSparkSession
243
+ from sparkless import Window as MockWindow
244
+ from sparkless import functions as mock_functions
245
+ from sparkless import spark_types as mock_types
246
+ from sparkless.functions import desc as mock_desc
247
+ except ImportError as e:
248
+ raise RuntimeError(
249
+ "sparkless is not installed. Install it with: pip install sparkless"
250
+ ) from e
251
+
252
+ configure_engine(
253
+ functions=mock_functions,
254
+ types=mock_types,
255
+ analysis_exception=MockAnalysisException,
256
+ window=MockWindow,
257
+ desc=mock_desc,
258
+ engine_name="mock",
259
+ engine_type=EngineType.MOCK,
260
+ dataframe_cls=MockDataFrame,
261
+ spark_session_cls=MockSparkSession,
262
+ column_cls=MockColumn,
263
+ )
264
+
265
+ return get_engine()
266
+
267
+
268
+ def _configure_pyspark_engine() -> EngineConfig:
269
+ """Configure PySpark engine."""
270
+ try:
271
+ from pyspark.sql import Column as PySparkColumn
272
+ from pyspark.sql import DataFrame as PySparkDataFrame
273
+ from pyspark.sql import SparkSession as PySparkSparkSession
274
+ from pyspark.sql import functions as pyspark_functions
275
+ from pyspark.sql import types as pyspark_types
276
+ from pyspark.sql.functions import desc as pyspark_desc
277
+ from pyspark.sql.utils import AnalysisException as PySparkAnalysisException
278
+ from pyspark.sql.window import Window as PySparkWindow
279
+ except ImportError as e:
280
+ raise RuntimeError("PySpark is not installed. Install it with: pip install pyspark") from e
281
+
282
+ configure_engine(
283
+ functions=pyspark_functions,
284
+ types=pyspark_types,
285
+ analysis_exception=PySparkAnalysisException,
286
+ window=PySparkWindow,
287
+ desc=pyspark_desc,
288
+ engine_name="pyspark",
289
+ engine_type=EngineType.PYSPARK,
290
+ dataframe_cls=PySparkDataFrame,
291
+ spark_session_cls=PySparkSparkSession,
292
+ column_cls=PySparkColumn,
293
+ )
294
+
295
+ return get_engine()
296
+
297
+
298
+ __all__ = [
299
+ "EngineType",
300
+ "EngineConfig",
301
+ "configure_engine",
302
+ "get_engine",
303
+ "reset_engine_state",
304
+ "detect_available_engines",
305
+ "auto_configure_engine",
306
+ ]
@@ -0,0 +1,150 @@
1
+ """
2
+ Parametrization support for running tests with both engines.
3
+
4
+ This module provides utilities for running tests with both sparkless (mock)
5
+ and PySpark (real) engines automatically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import uuid
12
+ from collections.abc import Generator
13
+ from typing import Any
14
+
15
+ import pytest
16
+
17
+ from .engine import EngineType, auto_configure_engine, detect_available_engines
18
+ from .session import SessionConfig, create_session
19
+
20
+
21
+ def pytest_configure(config: Any) -> None:
22
+ """Register custom pytest markers."""
23
+ config.addinivalue_line(
24
+ "markers",
25
+ "spark_engine(engine): Mark test to use specific engine (mock or pyspark)",
26
+ )
27
+ config.addinivalue_line(
28
+ "markers",
29
+ "parametrize_engines: Run test with both available engines",
30
+ )
31
+
32
+
33
+ @pytest.fixture(scope="function") # type: ignore[untyped-decorator]
34
+ def both_engines(request: pytest.FixtureRequest) -> Generator[tuple[Any, EngineType], None, None]:
35
+ """
36
+ Fixture that yields both available engines via parametrization.
37
+
38
+ This fixture is automatically parametrized to run with both engines.
39
+ The test will be called twice - once with mock engine, once with PySpark.
40
+
41
+ Example:
42
+ def test_both_engines(both_engines):
43
+ spark, engine_type = both_engines
44
+ df = spark.createDataFrame([{"id": 1}])
45
+ assert df.count() == 1
46
+ """
47
+ # Get the parametrized engine type
48
+ engine_type = getattr(request, "param", None)
49
+ if engine_type is None:
50
+ # If not parametrized, check availability and use default
51
+ available = detect_available_engines()
52
+ if EngineType.MOCK not in available or EngineType.PYSPARK not in available:
53
+ pytest.skip(f"Both engines not available. Available: {[e.value for e in available]}")
54
+ # Default to MOCK if not parametrized
55
+ engine_type = EngineType.MOCK
56
+
57
+ # Configure and create session for the specified engine
58
+ # Disable Delta Lake for parallel testing compatibility
59
+ unique_id = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
60
+ config = SessionConfig(app_name=f"test-{unique_id}", enable_delta=False)
61
+ auto_configure_engine(engine_type)
62
+ spark = create_session(engine_type=engine_type, config=config)
63
+
64
+ yield spark, engine_type
65
+
66
+ # Cleanup
67
+ try:
68
+ spark.stop()
69
+ except Exception:
70
+ pass
71
+
72
+
73
+ # Auto-parametrize both_engines fixture if both engines are available
74
+ def pytest_generate_tests(metafunc: Any) -> None:
75
+ """Generate test parameters for parametrize_engines marker and both_engines fixture."""
76
+ # Handle both_engines fixture parametrization
77
+ if "both_engines" in metafunc.fixturenames:
78
+ available = detect_available_engines()
79
+ if EngineType.MOCK in available and EngineType.PYSPARK in available:
80
+ # Parametrize the both_engines fixture
81
+ metafunc.parametrize(
82
+ "both_engines",
83
+ [EngineType.MOCK, EngineType.PYSPARK],
84
+ ids=["mock", "pyspark"],
85
+ indirect=True,
86
+ )
87
+ elif EngineType.MOCK in available or EngineType.PYSPARK in available:
88
+ # Only one engine available, parametrize with just that one
89
+ available_list = list(available)
90
+ metafunc.parametrize(
91
+ "both_engines",
92
+ available_list,
93
+ ids=[e.value for e in available_list],
94
+ indirect=True,
95
+ )
96
+
97
+ # Handle spark_session fixture parametrization for parametrize_engines marker
98
+ if "spark_session" in metafunc.fixturenames:
99
+ # Check if test has parametrize_engines marker
100
+ marker = metafunc.definition.get_closest_marker("parametrize_engines")
101
+ if marker:
102
+ available = detect_available_engines()
103
+
104
+ if EngineType.MOCK in available and EngineType.PYSPARK in available:
105
+ # Parametrize the spark_session fixture
106
+ metafunc.parametrize(
107
+ "spark_session",
108
+ [EngineType.MOCK, EngineType.PYSPARK],
109
+ ids=["mock", "pyspark"],
110
+ indirect=True,
111
+ )
112
+
113
+
114
+ def parametrize_engines(*args: Any, **kwargs: Any) -> Any:
115
+ """
116
+ Decorator to parametrize a test to run with both engines.
117
+
118
+ This is a convenience decorator that uses pytest.mark.parametrize
119
+ to run the test with both mock and pyspark engines.
120
+
121
+ Example:
122
+ @parametrize_engines
123
+ def test_my_function(spark_session):
124
+ df = spark_session.createDataFrame([{"id": 1}])
125
+ assert df.count() == 1
126
+
127
+ Note: This decorator requires both engines to be available.
128
+ """
129
+ available = detect_available_engines()
130
+
131
+ if EngineType.MOCK not in available or EngineType.PYSPARK not in available:
132
+ # Skip parametrization if both engines not available
133
+ return pytest.mark.skip(
134
+ f"Both engines not available. Available: {[e.value for e in available]}"
135
+ )(*args, **kwargs)
136
+
137
+ # Create parametrize marker
138
+ return pytest.mark.parametrize(
139
+ "engine_type",
140
+ [EngineType.MOCK, EngineType.PYSPARK],
141
+ ids=["mock", "pyspark"],
142
+ )(*args, **kwargs)
143
+
144
+
145
+ __all__ = [
146
+ "pytest_configure",
147
+ "both_engines",
148
+ "parametrize_engines",
149
+ "pytest_generate_tests",
150
+ ]