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,145 @@
1
+ """
2
+ Protocol definitions for engine-agnostic Spark interfaces.
3
+
4
+ These protocols represent the minimal surface area used by sparkless-testing.
5
+ Any engine (PySpark, sparkless, or other) must satisfy these to work with
6
+ the testing framework.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+
14
+ @runtime_checkable
15
+ class ColumnProtocol(Protocol):
16
+ """Column-like object with comparison and basic ops."""
17
+
18
+ def isNotNull(self) -> Any: ...
19
+ def isNull(self) -> Any: ...
20
+ def cast(self, dataType: Any) -> Any: ...
21
+ def __eq__(self, other: Any) -> Any: ...
22
+ def __ne__(self, other: Any) -> Any: ...
23
+ def __gt__(self, other: Any) -> Any: ...
24
+ def __lt__(self, other: Any) -> Any: ...
25
+ def __ge__(self, other: Any) -> Any: ...
26
+ def __le__(self, other: Any) -> Any: ...
27
+ def __add__(self, other: Any) -> Any: ...
28
+ def __sub__(self, other: Any) -> Any: ...
29
+ def __mul__(self, other: Any) -> Any: ...
30
+ def __truediv__(self, other: Any) -> Any: ...
31
+
32
+
33
+ @runtime_checkable
34
+ class DataFrameProtocol(Protocol):
35
+ """DataFrame-like object used throughout testing."""
36
+
37
+ # Core accessors
38
+ def schema(self) -> Any: ...
39
+ @property
40
+ def columns(self) -> list[str]: ...
41
+
42
+ # Actions
43
+ def count(self) -> int: ...
44
+ def collect(self) -> list[Any]: ...
45
+ def show(self, n: int = 20, truncate: bool = True) -> None: ...
46
+
47
+ # Transformations
48
+ def filter(self, condition: Any) -> Any: ...
49
+ def select(self, *cols: Any) -> Any: ...
50
+ def withColumn(self, colName: str, col: Any) -> Any: ...
51
+ def withColumnRenamed(self, existing: str, new: str) -> Any: ...
52
+ def groupBy(self, *cols: Any) -> Any: ...
53
+ def agg(self, *exprs: Any, **kwargs: Any) -> Any: ...
54
+ def limit(self, num: int) -> Any: ...
55
+ def cache(self) -> Any: ...
56
+
57
+ # SQL helpers
58
+ def createOrReplaceTempView(self, name: str) -> None: ...
59
+
60
+ # Writer
61
+ @property
62
+ def write(self) -> Any: ...
63
+
64
+
65
+ @runtime_checkable
66
+ class FunctionsProtocol(Protocol):
67
+ """Functions module interface (col, lit, aggregations, etc.)."""
68
+
69
+ def col(self, col_name: str) -> ColumnProtocol: ...
70
+ def expr(self, expr: str) -> ColumnProtocol: ...
71
+ def lit(self, value: Any) -> ColumnProtocol: ...
72
+ def when(self, condition: ColumnProtocol, value: Any) -> ColumnProtocol: ...
73
+ def count(self, col: Any = "*") -> ColumnProtocol: ...
74
+ def countDistinct(self, *cols: Any) -> ColumnProtocol: ...
75
+ def sum(self, col: Any) -> ColumnProtocol: ...
76
+ def max(self, col: Any) -> ColumnProtocol: ...
77
+ def min(self, col: Any) -> ColumnProtocol: ...
78
+ def avg(self, col: Any) -> ColumnProtocol: ...
79
+ def length(self, col: Any) -> ColumnProtocol: ...
80
+ def date_trunc(self, fmt: str, col: Any) -> ColumnProtocol: ...
81
+ def dayofweek(self, col: Any) -> ColumnProtocol: ...
82
+ def current_timestamp(self) -> ColumnProtocol: ...
83
+
84
+
85
+ @runtime_checkable
86
+ class TypesProtocol(Protocol):
87
+ """Types namespace used for schemas and fields."""
88
+
89
+ StructType: Any
90
+ StructField: Any
91
+ StringType: Any
92
+ IntegerType: Any
93
+ FloatType: Any
94
+ DoubleType: Any
95
+ LongType: Any
96
+ TimestampType: Any
97
+ BooleanType: Any
98
+
99
+
100
+ @runtime_checkable
101
+ class WindowProtocol(Protocol):
102
+ """Window spec placeholder."""
103
+
104
+ def orderBy(self, *cols: Any, **kwargs: Any) -> Any: ...
105
+ def partitionBy(self, *cols: Any) -> Any: ...
106
+
107
+
108
+ @runtime_checkable
109
+ class AnalysisExceptionProtocol(Protocol):
110
+ """Exception type placeholder for analysis errors."""
111
+
112
+ @property
113
+ def desc(self) -> str: ...
114
+
115
+
116
+ @runtime_checkable
117
+ class SparkSessionProtocol(Protocol):
118
+ """SparkSession-like interface."""
119
+
120
+ @property
121
+ def catalog(self) -> Any: ...
122
+ @property
123
+ def conf(self) -> Any: ...
124
+
125
+ def table(self, name: str) -> DataFrameProtocol: ...
126
+ def createDataFrame(
127
+ self, data: Any, schema: Any = None, samplingRatio: Any = None
128
+ ) -> DataFrameProtocol: ...
129
+ def sql(self, sqlQuery: str) -> DataFrameProtocol: ...
130
+ def stop(self) -> None: ...
131
+
132
+ # Builder/config checks
133
+ @property
134
+ def _jsparkSession(self) -> Any: ... # optional; used for ids
135
+
136
+
137
+ __all__ = [
138
+ "ColumnProtocol",
139
+ "DataFrameProtocol",
140
+ "FunctionsProtocol",
141
+ "TypesProtocol",
142
+ "WindowProtocol",
143
+ "AnalysisExceptionProtocol",
144
+ "SparkSessionProtocol",
145
+ ]
File without changes
@@ -0,0 +1,235 @@
1
+ """
2
+ Pytest fixtures for sparkless-testing.
3
+
4
+ This module provides pytest fixtures for easy test integration with both
5
+ sparkless (mock) and PySpark (real) engines.
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, get_engine
18
+ from .session import SessionConfig, create_mock_session, create_pyspark_session, create_session
19
+ from .utils import get_spark_mode
20
+
21
+
22
+ def _get_engine_type_from_marker(request: pytest.FixtureRequest) -> EngineType | None:
23
+ """Get engine type from pytest marker if present."""
24
+ marker = request.node.get_closest_marker("spark_engine")
25
+ if marker:
26
+ engine_arg = marker.args[0] if marker.args else None
27
+ if engine_arg:
28
+ engine_str = str(engine_arg).lower()
29
+ if engine_str == "mock":
30
+ return EngineType.MOCK
31
+ elif engine_str == "pyspark":
32
+ return EngineType.PYSPARK
33
+ return None
34
+
35
+
36
+ def _determine_engine_type(request: pytest.FixtureRequest) -> EngineType:
37
+ """Determine which engine type to use based on markers and environment."""
38
+ # Check pytest marker first
39
+ marker_engine = _get_engine_type_from_marker(request)
40
+ if marker_engine:
41
+ return marker_engine
42
+
43
+ # Check environment variable
44
+ spark_mode = get_spark_mode()
45
+ if spark_mode == "mock":
46
+ return EngineType.MOCK
47
+ elif spark_mode == "pyspark":
48
+ return EngineType.PYSPARK
49
+ else:
50
+ # Auto-detect
51
+ return EngineType.AUTO
52
+
53
+
54
+ @pytest.fixture(scope="function") # type: ignore[untyped-decorator]
55
+ def spark_session(request: pytest.FixtureRequest) -> Generator[Any, None, None]:
56
+ """
57
+ Main fixture that provides appropriate SparkSession based on configuration.
58
+
59
+ This fixture respects:
60
+ - `SPARK_MODE` environment variable: `mock`, `pyspark`, or `auto` (default)
61
+ - `@pytest.mark.spark_engine("mock")` or `@pytest.mark.spark_engine("pyspark")` markers
62
+ - Auto-detection if only one engine is available
63
+ - Parametrization via `@pytest.mark.parametrize_engines`
64
+
65
+ Example:
66
+ def test_my_function(spark_session):
67
+ df = spark_session.createDataFrame([{"id": 1}])
68
+ assert df.count() == 1
69
+ """
70
+ # Check if parametrized (for parametrize_engines support)
71
+ param = getattr(request, "param", None)
72
+ if param is not None:
73
+ # Use the parametrized engine type
74
+ engine_type = param
75
+ else:
76
+ # Determine engine type from markers/environment
77
+ engine_type = _determine_engine_type(request)
78
+
79
+ # Auto-configure engine if not already configured
80
+ try:
81
+ get_engine()
82
+ except RuntimeError:
83
+ if engine_type == EngineType.AUTO:
84
+ auto_configure_engine()
85
+ else:
86
+ auto_configure_engine(engine_type)
87
+
88
+ # Create session with Delta Lake disabled for parallel testing compatibility
89
+ # Use process ID and UUID for unique session names (pytest-xdist uses separate processes)
90
+ unique_id = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
91
+ config = SessionConfig(app_name=f"test-{unique_id}", enable_delta=False)
92
+ spark = create_session(engine_type=engine_type, config=config)
93
+
94
+ yield spark
95
+
96
+ # Cleanup
97
+ try:
98
+ spark.stop()
99
+ except Exception:
100
+ pass
101
+
102
+
103
+ @pytest.fixture(scope="function") # type: ignore[untyped-decorator]
104
+ def mock_spark_session() -> Generator[Any, None, None]:
105
+ """
106
+ Fixture that explicitly uses sparkless (mock) engine.
107
+
108
+ Example:
109
+ def test_mock_only(mock_spark_session):
110
+ # Only runs with sparkless
111
+ df = mock_spark_session.createDataFrame([{"id": 1}])
112
+ assert df.count() == 1
113
+ """
114
+ # Auto-configure mock engine if not already configured
115
+ try:
116
+ get_engine()
117
+ except RuntimeError:
118
+ auto_configure_engine(EngineType.MOCK)
119
+
120
+ spark = create_mock_session()
121
+
122
+ yield spark
123
+
124
+ # Cleanup
125
+ try:
126
+ spark.stop()
127
+ except Exception:
128
+ pass
129
+
130
+
131
+ @pytest.fixture(scope="function") # type: ignore[untyped-decorator]
132
+ def pyspark_session() -> Generator[Any, None, None]:
133
+ """
134
+ Fixture that explicitly uses PySpark (real) engine.
135
+
136
+ Example:
137
+ def test_pyspark_only(pyspark_session):
138
+ # Only runs with PySpark
139
+ df = pyspark_session.createDataFrame([{"id": 1}])
140
+ assert df.count() == 1
141
+ """
142
+ # Auto-configure PySpark engine if not already configured
143
+ try:
144
+ get_engine()
145
+ except RuntimeError:
146
+ auto_configure_engine(EngineType.PYSPARK)
147
+
148
+ # Disable Delta Lake for parallel testing compatibility
149
+ unique_id = f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
150
+ config = SessionConfig(app_name=f"test-{unique_id}", enable_delta=False)
151
+ spark = create_pyspark_session(config=config)
152
+
153
+ yield spark
154
+
155
+ # Cleanup
156
+ try:
157
+ spark.stop()
158
+ except Exception:
159
+ pass
160
+
161
+
162
+ @pytest.fixture(scope="function") # type: ignore[untyped-decorator]
163
+ def spark_functions() -> Any:
164
+ """
165
+ Fixture that provides the functions module (F) for the current engine.
166
+
167
+ **Important**: For PySpark, functions like `F.col()` require an active SparkContext.
168
+ Always use this fixture together with `spark_session` to ensure the session is active.
169
+
170
+ Example:
171
+ def test_with_functions(spark_session, spark_functions):
172
+ F = spark_functions
173
+ df = spark_session.createDataFrame([{"name": "Alice"}])
174
+ result = df.select(F.upper(F.col("name"))).collect()
175
+ assert result[0][0] == "ALICE"
176
+
177
+ Note:
178
+ If you use this fixture without `spark_session`, PySpark functions may fail
179
+ with an AssertionError if no SparkContext is active. Always use both fixtures together.
180
+ """
181
+ try:
182
+ return get_engine().functions
183
+ except RuntimeError:
184
+ # Auto-configure if not configured
185
+ auto_configure_engine()
186
+ return get_engine().functions
187
+
188
+
189
+ @pytest.fixture(scope="function") # type: ignore[untyped-decorator]
190
+ def spark_types() -> Any:
191
+ """
192
+ Fixture that provides the types module for the current engine.
193
+
194
+ Example:
195
+ def test_with_types(spark_session, spark_types):
196
+ from spark_types import StructType, StructField, StringType
197
+ schema = StructType([StructField("name", StringType())])
198
+ df = spark_session.createDataFrame([{"name": "Alice"}], schema)
199
+ """
200
+ try:
201
+ return get_engine().types
202
+ except RuntimeError:
203
+ # Auto-configure if not configured
204
+ auto_configure_engine()
205
+ return get_engine().types
206
+
207
+
208
+ @pytest.fixture(scope="function") # type: ignore[untyped-decorator]
209
+ def spark_engine_type() -> str:
210
+ """
211
+ Fixture that provides the current engine type as a string.
212
+
213
+ Returns:
214
+ 'mock' or 'pyspark'
215
+
216
+ Example:
217
+ def test_engine_type(spark_engine_type):
218
+ assert spark_engine_type in ("mock", "pyspark")
219
+ """
220
+ try:
221
+ return get_engine().engine_name
222
+ except RuntimeError:
223
+ # Auto-configure if not configured
224
+ auto_configure_engine()
225
+ return get_engine().engine_name
226
+
227
+
228
+ __all__ = [
229
+ "spark_session",
230
+ "mock_spark_session",
231
+ "pyspark_session",
232
+ "spark_functions",
233
+ "spark_types",
234
+ "spark_engine_type",
235
+ ]
@@ -0,0 +1,262 @@
1
+ """
2
+ Session factory for creating SparkSession instances.
3
+
4
+ This module provides functions to create SparkSession instances for both
5
+ sparkless (mock) and PySpark (real) engines with proper configuration.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import uuid
12
+ import warnings
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from .engine import EngineType, auto_configure_engine, get_engine
17
+ from .utils import get_app_name_prefix, get_warehouse_dir
18
+
19
+
20
+ @dataclass
21
+ class SessionConfig:
22
+ """Configuration for SparkSession creation."""
23
+
24
+ app_name: str
25
+ warehouse_dir: str | None = None
26
+ enable_delta: bool = True
27
+ enable_ui: bool = False
28
+ shuffle_partitions: int = 1
29
+ parallelism: int = 1
30
+ adaptive_enabled: bool = False
31
+
32
+
33
+ def create_mock_session(
34
+ app_name: str | None = None,
35
+ config: SessionConfig | None = None,
36
+ ) -> Any:
37
+ """
38
+ Create a sparkless (mock) SparkSession.
39
+
40
+ Args:
41
+ app_name: Application name (defaults to auto-generated)
42
+ config: Optional SessionConfig (currently unused for mock sessions)
43
+
44
+ Returns:
45
+ SparkSession instance from sparkless
46
+ """
47
+ try:
48
+ from sparkless import SparkSession
49
+ except ImportError as e:
50
+ raise RuntimeError(
51
+ "sparkless is not installed. Install it with: pip install sparkless"
52
+ ) from e
53
+
54
+ # Auto-configure engine if not already configured
55
+ try:
56
+ get_engine()
57
+ except RuntimeError:
58
+ auto_configure_engine(EngineType.MOCK)
59
+
60
+ # Generate app name if not provided
61
+ if app_name is None:
62
+ app_name = f"{get_app_name_prefix()}-mock-{os.getpid()}"
63
+
64
+ # Create mock Spark session
65
+ spark = SparkSession(app_name)
66
+
67
+ # Create test schema if possible
68
+ try:
69
+ spark.sql("CREATE SCHEMA IF NOT EXISTS test_schema")
70
+ except Exception:
71
+ pass # Ignore schema creation errors
72
+
73
+ return spark
74
+
75
+
76
+ def create_pyspark_session(
77
+ app_name: str | None = None,
78
+ config: SessionConfig | None = None,
79
+ ) -> Any:
80
+ """
81
+ Create a PySpark SparkSession with optional Delta Lake support.
82
+
83
+ Args:
84
+ app_name: Application name (defaults to auto-generated)
85
+ config: Optional SessionConfig for session customization
86
+
87
+ Returns:
88
+ SparkSession instance from PySpark
89
+ """
90
+ try:
91
+ from pyspark.sql import SparkSession
92
+ except ImportError as e:
93
+ raise RuntimeError("PySpark is not installed. Install it with: pip install pyspark") from e
94
+
95
+ # Auto-configure engine if not already configured
96
+ try:
97
+ get_engine()
98
+ except RuntimeError:
99
+ auto_configure_engine(EngineType.PYSPARK)
100
+
101
+ # Skip environment variable manipulation for parallel testing compatibility
102
+ # PySpark will use the current Python interpreter by default
103
+ # Setting these can cause contention in parallel execution
104
+
105
+ # Get worker ID for concurrent testing isolation (pytest-xdist)
106
+ worker_id = os.environ.get("PYTEST_XDIST_WORKER", "gw0")
107
+
108
+ # Generate app name if not provided
109
+ # Priority: explicit app_name parameter > config.app_name > auto-generated
110
+ if app_name is None:
111
+ if config and config.app_name:
112
+ app_name = config.app_name
113
+ else:
114
+ app_name = f"{get_app_name_prefix()}-pyspark-{worker_id}-{uuid.uuid4().hex[:8]}"
115
+
116
+ # Determine warehouse directory with unique identifier per process/test
117
+ warehouse_dir = None
118
+ if config and config.warehouse_dir:
119
+ warehouse_dir = config.warehouse_dir
120
+ else:
121
+ # Add unique identifier to avoid collisions across parallel workers
122
+ unique_id = uuid.uuid4().hex[:8]
123
+ base_dir = get_warehouse_dir() or f"/tmp/spark-warehouse-{os.getpid()}-{unique_id}"
124
+ warehouse_dir = base_dir
125
+
126
+ # Build Spark session builder - use local[1] to ensure single-threaded execution
127
+ # This prevents resource contention in parallel test execution
128
+ builder = (
129
+ SparkSession.builder.appName(app_name)
130
+ .master("local[1]") # Explicitly use 1 thread, not local[*]
131
+ .config("spark.sql.warehouse.dir", warehouse_dir)
132
+ .config("spark.driver.host", "127.0.0.1")
133
+ .config("spark.driver.bindAddress", "127.0.0.1")
134
+ .config("spark.executor.cores", "1") # Ensure only 1 core per executor
135
+ .config("spark.executor.instances", "1") # Ensure only 1 executor instance
136
+ )
137
+
138
+ # Only add optional configs if explicitly provided
139
+ if config:
140
+ if config.enable_ui:
141
+ builder = builder.config("spark.ui.enabled", "true")
142
+ if config.shuffle_partitions != 1:
143
+ builder = builder.config("spark.sql.shuffle.partitions", str(config.shuffle_partitions))
144
+ if config.parallelism != 1:
145
+ builder = builder.config("spark.default.parallelism", str(config.parallelism))
146
+ if config.adaptive_enabled:
147
+ builder = builder.config("spark.sql.adaptive.enabled", "true")
148
+
149
+ # Add Delta Lake support if enabled and available
150
+ enable_delta = config.enable_delta if config else True
151
+ if enable_delta:
152
+ try:
153
+ from delta import configure_spark_with_delta_pip
154
+
155
+ builder = builder.config(
156
+ "spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension"
157
+ ).config(
158
+ "spark.sql.catalog.spark_catalog",
159
+ "org.apache.spark.sql.delta.catalog.DeltaCatalog",
160
+ )
161
+ builder = configure_spark_with_delta_pip(builder)
162
+ except ImportError:
163
+ # Delta Lake not available, continue without it
164
+ pass
165
+
166
+ # Stop any existing session in this process first to ensure clean state
167
+ # This prevents conflicts when multiple tests run in the same process
168
+ try:
169
+ existing = SparkSession.getActiveSession()
170
+ if existing is not None:
171
+ existing.stop()
172
+ except Exception:
173
+ pass
174
+
175
+ # Use getOrCreate() with unique app name - PySpark will create a new session
176
+ # for each unique app name, ensuring test isolation
177
+ spark = builder.getOrCreate()
178
+
179
+ # Minimal post-creation setup - avoid operations that could block
180
+ # Set log level to WARN to reduce noise (non-blocking)
181
+ try:
182
+ if hasattr(spark, "sparkContext") and spark.sparkContext:
183
+ spark.sparkContext.setLogLevel("WARN")
184
+ except Exception:
185
+ # Ignore errors - log level setting is not critical
186
+ pass
187
+
188
+ # No environment variable restoration needed since we're not modifying them
189
+
190
+ return spark
191
+
192
+
193
+ def create_session(
194
+ engine_type: EngineType | None = None,
195
+ app_name: str | None = None,
196
+ config: SessionConfig | None = None,
197
+ ) -> Any:
198
+ """
199
+ Factory function that creates appropriate session based on engine type.
200
+
201
+ Args:
202
+ engine_type: Engine type to use (defaults to auto-detect)
203
+ app_name: Application name (defaults to auto-generated)
204
+ config: Optional SessionConfig for session customization
205
+
206
+ Returns:
207
+ SparkSession instance
208
+ """
209
+ # Auto-detect engine type if not specified
210
+ if engine_type is None or engine_type == EngineType.AUTO:
211
+ from .engine import detect_available_engines
212
+
213
+ available = detect_available_engines()
214
+ if EngineType.MOCK in available:
215
+ engine_type = EngineType.MOCK
216
+ elif EngineType.PYSPARK in available:
217
+ engine_type = EngineType.PYSPARK
218
+ else:
219
+ raise RuntimeError(
220
+ "No Spark engines available. Install either 'sparkless' or 'pyspark'."
221
+ )
222
+
223
+ # Create session based on engine type
224
+ if engine_type == EngineType.MOCK:
225
+ return create_mock_session(app_name, config)
226
+ elif engine_type == EngineType.PYSPARK:
227
+ return create_pyspark_session(app_name, config)
228
+ else:
229
+ raise ValueError(f"Unknown engine type: {engine_type}")
230
+
231
+
232
+ def stop_all_sessions() -> None:
233
+ """
234
+ Stop all active Spark sessions.
235
+
236
+ This is a cleanup utility for test teardown.
237
+ """
238
+ # Try to stop PySpark sessions
239
+ try:
240
+ from pyspark.sql import SparkSession
241
+
242
+ active_session = SparkSession.getActiveSession()
243
+ if active_session:
244
+ active_session.stop()
245
+ except Exception as e:
246
+ warnings.warn(
247
+ f"Failed to stop existing PySpark session during cleanup: {e}",
248
+ UserWarning,
249
+ stacklevel=2,
250
+ )
251
+
252
+ # Note: sparkless sessions don't need explicit stopping,
253
+ # but we could add cleanup here if needed
254
+
255
+
256
+ __all__ = [
257
+ "SessionConfig",
258
+ "create_mock_session",
259
+ "create_pyspark_session",
260
+ "create_session",
261
+ "stop_all_sessions",
262
+ ]