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.
- sparkless_testing/__init__.py +87 -0
- sparkless_testing/conftest.py +39 -0
- sparkless_testing/engine.py +306 -0
- sparkless_testing/parametrize.py +150 -0
- sparkless_testing/protocols.py +145 -0
- sparkless_testing/py.typed +0 -0
- sparkless_testing/pytest_fixtures.py +235 -0
- sparkless_testing/session.py +262 -0
- sparkless_testing/utils.py +209 -0
- sparkless_testing-0.1.0.dist-info/METADATA +445 -0
- sparkless_testing-0.1.0.dist-info/RECORD +14 -0
- sparkless_testing-0.1.0.dist-info/WHEEL +5 -0
- sparkless_testing-0.1.0.dist-info/licenses/LICENSE +22 -0
- sparkless_testing-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|