sparkless-testing 0.2.0__tar.gz → 4.19.0__tar.gz
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-4.19.0/PKG-INFO +27 -0
- sparkless_testing-4.19.0/README.md +15 -0
- sparkless_testing-4.19.0/pyproject.toml +23 -0
- sparkless_testing-4.19.0/sparkless_testing/__init__.py +120 -0
- sparkless_testing-4.19.0/sparkless_testing/comparison.py +409 -0
- sparkless_testing-4.19.0/sparkless_testing/compat.py +28 -0
- sparkless_testing-4.19.0/sparkless_testing/fixtures.py +265 -0
- sparkless_testing-4.19.0/sparkless_testing/imports.py +214 -0
- sparkless_testing-4.19.0/sparkless_testing/jdbc.py +311 -0
- sparkless_testing-4.19.0/sparkless_testing/mode.py +120 -0
- sparkless_testing-4.19.0/sparkless_testing/plugin.py +126 -0
- sparkless_testing-4.19.0/sparkless_testing/session.py +532 -0
- sparkless_testing-4.19.0/sparkless_testing.egg-info/PKG-INFO +27 -0
- sparkless_testing-4.19.0/sparkless_testing.egg-info/SOURCES.txt +17 -0
- sparkless_testing-4.19.0/sparkless_testing.egg-info/requires.txt +1 -0
- sparkless_testing-0.2.0/LICENSE +0 -22
- sparkless_testing-0.2.0/PKG-INFO +0 -423
- sparkless_testing-0.2.0/README.md +0 -385
- sparkless_testing-0.2.0/pyproject.toml +0 -138
- sparkless_testing-0.2.0/sparkless_testing/__init__.py +0 -87
- sparkless_testing-0.2.0/sparkless_testing/conftest.py +0 -39
- sparkless_testing-0.2.0/sparkless_testing/engine.py +0 -306
- sparkless_testing-0.2.0/sparkless_testing/parametrize.py +0 -149
- sparkless_testing-0.2.0/sparkless_testing/protocols.py +0 -145
- sparkless_testing-0.2.0/sparkless_testing/pytest_fixtures.py +0 -233
- sparkless_testing-0.2.0/sparkless_testing/session.py +0 -244
- sparkless_testing-0.2.0/sparkless_testing/utils.py +0 -209
- sparkless_testing-0.2.0/sparkless_testing.egg-info/PKG-INFO +0 -423
- sparkless_testing-0.2.0/sparkless_testing.egg-info/SOURCES.txt +0 -22
- sparkless_testing-0.2.0/sparkless_testing.egg-info/requires.txt +0 -15
- sparkless_testing-0.2.0/tests/test_engine.py +0 -389
- sparkless_testing-0.2.0/tests/test_fixtures.py +0 -344
- sparkless_testing-0.2.0/tests/test_parametrize.py +0 -150
- sparkless_testing-0.2.0/tests/test_session.py +0 -666
- sparkless_testing-0.2.0/tests/test_utils.py +0 -428
- {sparkless_testing-0.2.0 → sparkless_testing-4.19.0}/setup.cfg +0 -0
- {sparkless_testing-0.2.0 → sparkless_testing-4.19.0}/sparkless_testing/py.typed +0 -0
- {sparkless_testing-0.2.0 → sparkless_testing-4.19.0}/sparkless_testing.egg-info/dependency_links.txt +0 -0
- {sparkless_testing-0.2.0 → sparkless_testing-4.19.0}/sparkless_testing.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sparkless-testing
|
|
3
|
+
Version: 4.19.0
|
|
4
|
+
Summary: Backend-independent pytest helpers for Sparkless and PySpark test suites.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/eddiethedean/robin-sparkless
|
|
7
|
+
Project-URL: Documentation, https://robin-sparkless.readthedocs.io/
|
|
8
|
+
Project-URL: Repository, https://github.com/eddiethedean/robin-sparkless
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: pytest>=7
|
|
12
|
+
|
|
13
|
+
# sparkless-testing
|
|
14
|
+
|
|
15
|
+
Backend-independent pytest fixtures and helpers for test suites that run against
|
|
16
|
+
Sparkless or PySpark. Install `sparkless[testing]` when testing a Sparkless
|
|
17
|
+
application, or install `sparkless-testing` directly for a suite that selects its
|
|
18
|
+
backend independently.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
pytest_plugins = ["sparkless_testing"]
|
|
22
|
+
|
|
23
|
+
from sparkless_testing import get_imports
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The package does not import Sparkless, PySpark, a native engine, or a JVM until a
|
|
27
|
+
backend-dependent helper is called.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# sparkless-testing
|
|
2
|
+
|
|
3
|
+
Backend-independent pytest fixtures and helpers for test suites that run against
|
|
4
|
+
Sparkless or PySpark. Install `sparkless[testing]` when testing a Sparkless
|
|
5
|
+
application, or install `sparkless-testing` directly for a suite that selects its
|
|
6
|
+
backend independently.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
pytest_plugins = ["sparkless_testing"]
|
|
10
|
+
|
|
11
|
+
from sparkless_testing import get_imports
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The package does not import Sparkless, PySpark, a native engine, or a JVM until a
|
|
15
|
+
backend-dependent helper is called.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sparkless-testing"
|
|
3
|
+
version = "4.19.0"
|
|
4
|
+
description = "Backend-independent pytest helpers for Sparkless and PySpark test suites."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dependencies = ["pytest>=7"]
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
Homepage = "https://github.com/eddiethedean/robin-sparkless"
|
|
12
|
+
Documentation = "https://robin-sparkless.readthedocs.io/"
|
|
13
|
+
Repository = "https://github.com/eddiethedean/robin-sparkless"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["setuptools>=77"]
|
|
17
|
+
build-backend = "setuptools.build_meta"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools]
|
|
20
|
+
packages = ["sparkless_testing"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.package-data]
|
|
23
|
+
sparkless_testing = ["py.typed"]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Dual sparkless/pyspark testing framework.
|
|
2
|
+
|
|
3
|
+
This module provides a unified API for writing tests that can run against
|
|
4
|
+
both sparkless (Rust/Polars) and PySpark backends. Use the SPARKLESS_TEST_MODE
|
|
5
|
+
environment variable to switch between modes:
|
|
6
|
+
|
|
7
|
+
- SPARKLESS_TEST_MODE=sparkless (default): Run with sparkless
|
|
8
|
+
- SPARKLESS_TEST_MODE=pyspark: Run with PySpark
|
|
9
|
+
|
|
10
|
+
Quick Start (for external projects):
|
|
11
|
+
|
|
12
|
+
1. Add to your conftest.py:
|
|
13
|
+
|
|
14
|
+
pytest_plugins = ["sparkless_testing"]
|
|
15
|
+
|
|
16
|
+
2. Use the `spark` fixture in your tests:
|
|
17
|
+
|
|
18
|
+
def test_filter(spark):
|
|
19
|
+
df = spark.createDataFrame([(1, "a"), (2, "b")], ["id", "val"])
|
|
20
|
+
result = df.filter(df.id > 1).collect()
|
|
21
|
+
assert len(result) == 1
|
|
22
|
+
|
|
23
|
+
3. Run tests:
|
|
24
|
+
|
|
25
|
+
# Run with sparkless (default)
|
|
26
|
+
pytest tests/
|
|
27
|
+
|
|
28
|
+
# Run with PySpark
|
|
29
|
+
SPARKLESS_TEST_MODE=pyspark pytest tests/
|
|
30
|
+
|
|
31
|
+
For more control, use the module directly:
|
|
32
|
+
|
|
33
|
+
from sparkless_testing import Mode, create_session, get_imports
|
|
34
|
+
|
|
35
|
+
spark = create_session(mode=Mode.SPARKLESS)
|
|
36
|
+
imports = get_imports()
|
|
37
|
+
F = imports.F
|
|
38
|
+
|
|
39
|
+
Example usage with comparison utilities:
|
|
40
|
+
|
|
41
|
+
from sparkless_testing import assert_dataframes_equal, assert_rows_equal
|
|
42
|
+
|
|
43
|
+
def test_transform(spark):
|
|
44
|
+
df1 = transform_a(spark)
|
|
45
|
+
df2 = transform_b(spark)
|
|
46
|
+
assert_dataframes_equal(df1, df2, check_order=False)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
# Mode detection and configuration
|
|
52
|
+
from .mode import (
|
|
53
|
+
Mode,
|
|
54
|
+
ENV_VAR_NAME,
|
|
55
|
+
get_mode,
|
|
56
|
+
is_pyspark_mode,
|
|
57
|
+
is_sparkless_mode,
|
|
58
|
+
set_mode,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
from .compat import (
|
|
62
|
+
ENV_COMPAT,
|
|
63
|
+
DEFAULT_COMPAT,
|
|
64
|
+
get_compat_profile,
|
|
65
|
+
apply_compat_to_session,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Session creation
|
|
69
|
+
from .session import create_session
|
|
70
|
+
|
|
71
|
+
# Unified imports
|
|
72
|
+
from .imports import SparkImports, get_imports
|
|
73
|
+
|
|
74
|
+
# Comparison utilities
|
|
75
|
+
from .comparison import (
|
|
76
|
+
ComparisonResult,
|
|
77
|
+
compare_dataframes,
|
|
78
|
+
assert_dataframes_equal,
|
|
79
|
+
assert_rows_equal,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Pytest fixtures (for direct import if not using plugin)
|
|
83
|
+
from .fixtures import (
|
|
84
|
+
spark,
|
|
85
|
+
spark_mode,
|
|
86
|
+
spark_imports,
|
|
87
|
+
isolated_session,
|
|
88
|
+
table_prefix,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
# Mode
|
|
93
|
+
"Mode",
|
|
94
|
+
"ENV_VAR_NAME",
|
|
95
|
+
"get_mode",
|
|
96
|
+
"is_pyspark_mode",
|
|
97
|
+
"is_sparkless_mode",
|
|
98
|
+
"set_mode",
|
|
99
|
+
# Compat profile
|
|
100
|
+
"ENV_COMPAT",
|
|
101
|
+
"DEFAULT_COMPAT",
|
|
102
|
+
"get_compat_profile",
|
|
103
|
+
"apply_compat_to_session",
|
|
104
|
+
# Session
|
|
105
|
+
"create_session",
|
|
106
|
+
# Imports
|
|
107
|
+
"SparkImports",
|
|
108
|
+
"get_imports",
|
|
109
|
+
# Comparison
|
|
110
|
+
"ComparisonResult",
|
|
111
|
+
"compare_dataframes",
|
|
112
|
+
"assert_dataframes_equal",
|
|
113
|
+
"assert_rows_equal",
|
|
114
|
+
# Fixtures
|
|
115
|
+
"spark",
|
|
116
|
+
"spark_mode",
|
|
117
|
+
"spark_imports",
|
|
118
|
+
"isolated_session",
|
|
119
|
+
"table_prefix",
|
|
120
|
+
]
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""DataFrame comparison utilities for testing.
|
|
2
|
+
|
|
3
|
+
This module provides functions to compare DataFrames and rows between
|
|
4
|
+
sparkless and PySpark, with appropriate tolerance and normalization
|
|
5
|
+
for floating point values, dates, and other types.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import date, datetime
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ComparisonResult:
|
|
18
|
+
"""Result of a DataFrame comparison.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
equivalent: True if the DataFrames are equivalent.
|
|
22
|
+
errors: List of error messages describing differences.
|
|
23
|
+
row_count_match: True if row counts match.
|
|
24
|
+
schema_match: True if schemas match.
|
|
25
|
+
column_match: True if column names match.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
equivalent: bool
|
|
29
|
+
errors: List[str] = field(default_factory=list)
|
|
30
|
+
row_count_match: bool = True
|
|
31
|
+
schema_match: bool = True
|
|
32
|
+
column_match: bool = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def compare_dataframes(
|
|
36
|
+
actual: Any,
|
|
37
|
+
expected: Any,
|
|
38
|
+
*,
|
|
39
|
+
tolerance: float = 1e-6,
|
|
40
|
+
check_schema: bool = True,
|
|
41
|
+
check_order: bool = False,
|
|
42
|
+
) -> ComparisonResult:
|
|
43
|
+
"""Compare two DataFrames and return a detailed comparison result.
|
|
44
|
+
|
|
45
|
+
This function handles both DataFrame objects and expected output
|
|
46
|
+
dictionaries (with 'data' and optionally 'schema' keys).
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
actual: The actual DataFrame to compare.
|
|
50
|
+
expected: The expected DataFrame or expected output dict.
|
|
51
|
+
tolerance: Tolerance for floating point comparisons.
|
|
52
|
+
check_schema: Whether to compare schemas.
|
|
53
|
+
check_order: Whether row order must match.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
ComparisonResult: Detailed comparison result.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
>>> result = compare_dataframes(df1, df2, tolerance=1e-5)
|
|
60
|
+
>>> if not result.equivalent:
|
|
61
|
+
... print(result.errors)
|
|
62
|
+
"""
|
|
63
|
+
errors: List[str] = []
|
|
64
|
+
|
|
65
|
+
# Handle expected as dict (from expected output files)
|
|
66
|
+
if isinstance(expected, dict):
|
|
67
|
+
expected_data = expected.get("data", [])
|
|
68
|
+
expected_schema = expected.get("schema")
|
|
69
|
+
else:
|
|
70
|
+
# Expected is a DataFrame
|
|
71
|
+
try:
|
|
72
|
+
expected_data = expected.collect()
|
|
73
|
+
expected_schema = expected.schema if check_schema else None
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return ComparisonResult(
|
|
76
|
+
equivalent=False,
|
|
77
|
+
errors=[f"Failed to collect expected DataFrame: {e}"],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Collect actual data
|
|
81
|
+
try:
|
|
82
|
+
actual_data = actual.collect()
|
|
83
|
+
actual_schema = actual.schema if check_schema else None
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return ComparisonResult(
|
|
86
|
+
equivalent=False,
|
|
87
|
+
errors=[f"Failed to collect actual DataFrame: {e}"],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Compare row counts
|
|
91
|
+
row_count_match = len(actual_data) == len(expected_data)
|
|
92
|
+
if not row_count_match:
|
|
93
|
+
errors.append(
|
|
94
|
+
f"Row count mismatch: got {len(actual_data)}, expected {len(expected_data)}"
|
|
95
|
+
)
|
|
96
|
+
return ComparisonResult(
|
|
97
|
+
equivalent=False,
|
|
98
|
+
errors=errors,
|
|
99
|
+
row_count_match=False,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Compare schemas if requested
|
|
103
|
+
schema_match = True
|
|
104
|
+
if check_schema and actual_schema is not None and expected_schema is not None:
|
|
105
|
+
schema_match = _compare_schemas(actual_schema, expected_schema)
|
|
106
|
+
if not schema_match:
|
|
107
|
+
errors.append("Schema mismatch")
|
|
108
|
+
|
|
109
|
+
# Compare columns
|
|
110
|
+
try:
|
|
111
|
+
actual_columns = actual.columns
|
|
112
|
+
if isinstance(expected, dict):
|
|
113
|
+
# Use first row that is a dict for column names; avoid IndexError/AttributeError
|
|
114
|
+
first_dict = next(
|
|
115
|
+
(r for r in expected_data if isinstance(r, dict)),
|
|
116
|
+
None,
|
|
117
|
+
)
|
|
118
|
+
expected_columns = list(first_dict.keys()) if first_dict else []
|
|
119
|
+
else:
|
|
120
|
+
expected_columns = expected.columns
|
|
121
|
+
|
|
122
|
+
column_match = set(actual_columns) == set(expected_columns)
|
|
123
|
+
if not column_match:
|
|
124
|
+
errors.append(
|
|
125
|
+
f"Column mismatch: got {set(actual_columns)}, expected {set(expected_columns)}"
|
|
126
|
+
)
|
|
127
|
+
except Exception:
|
|
128
|
+
column_match = True # Skip column check if it fails
|
|
129
|
+
|
|
130
|
+
# Convert to dicts for comparison
|
|
131
|
+
actual_dicts = [_row_to_dict(r) for r in actual_data]
|
|
132
|
+
if isinstance(expected, dict):
|
|
133
|
+
expected_dicts = expected_data
|
|
134
|
+
else:
|
|
135
|
+
expected_dicts = [_row_to_dict(r) for r in expected_data]
|
|
136
|
+
|
|
137
|
+
# Sort if order doesn't matter
|
|
138
|
+
if not check_order:
|
|
139
|
+
actual_dicts = _sort_rows_by_key(actual_dicts)
|
|
140
|
+
expected_dicts = _sort_rows_by_key(expected_dicts)
|
|
141
|
+
|
|
142
|
+
# Compare rows
|
|
143
|
+
for i, (actual_row, expected_row) in enumerate(zip(actual_dicts, expected_dicts)):
|
|
144
|
+
row_errors = _compare_row_dicts(actual_row, expected_row, tolerance, i)
|
|
145
|
+
errors.extend(row_errors)
|
|
146
|
+
|
|
147
|
+
return ComparisonResult(
|
|
148
|
+
equivalent=len(errors) == 0,
|
|
149
|
+
errors=errors,
|
|
150
|
+
row_count_match=row_count_match,
|
|
151
|
+
schema_match=schema_match,
|
|
152
|
+
column_match=column_match,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def assert_dataframes_equal(
|
|
157
|
+
actual: Any,
|
|
158
|
+
expected: Any,
|
|
159
|
+
*,
|
|
160
|
+
tolerance: float = 1e-6,
|
|
161
|
+
check_schema: bool = True,
|
|
162
|
+
check_order: bool = False,
|
|
163
|
+
msg: Optional[str] = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Assert that two DataFrames are equal.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
actual: The actual DataFrame to compare.
|
|
169
|
+
expected: The expected DataFrame or expected output dict.
|
|
170
|
+
tolerance: Tolerance for floating point comparisons.
|
|
171
|
+
check_schema: Whether to compare schemas.
|
|
172
|
+
check_order: Whether row order must match.
|
|
173
|
+
msg: Optional custom error message prefix.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
AssertionError: If DataFrames are not equal.
|
|
177
|
+
|
|
178
|
+
Example:
|
|
179
|
+
>>> assert_dataframes_equal(df1, df2, tolerance=1e-5)
|
|
180
|
+
>>> assert_dataframes_equal(df1, df2, check_order=False)
|
|
181
|
+
"""
|
|
182
|
+
result = compare_dataframes(
|
|
183
|
+
actual,
|
|
184
|
+
expected,
|
|
185
|
+
tolerance=tolerance,
|
|
186
|
+
check_schema=check_schema,
|
|
187
|
+
check_order=check_order,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if not result.equivalent:
|
|
191
|
+
error_msg = "\n".join(result.errors)
|
|
192
|
+
if msg:
|
|
193
|
+
error_msg = f"{msg}: {error_msg}"
|
|
194
|
+
raise AssertionError(error_msg)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def assert_rows_equal(
|
|
198
|
+
actual: List[Any],
|
|
199
|
+
expected: List[Any],
|
|
200
|
+
*,
|
|
201
|
+
order_matters: bool = True,
|
|
202
|
+
tolerance: float = 1e-6,
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Compare two lists of rows (dicts or Row objects).
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
actual: Result from DataFrame.collect() (any backend).
|
|
208
|
+
expected: Expected rows (e.g. from PySpark or precomputed).
|
|
209
|
+
order_matters: If False, sort both before comparing.
|
|
210
|
+
tolerance: Tolerance for floating point comparisons.
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
AssertionError: If row counts differ or any row differs.
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
>>> actual = df.collect()
|
|
217
|
+
>>> expected = [{"id": 1, "val": "a"}, {"id": 2, "val": "b"}]
|
|
218
|
+
>>> assert_rows_equal(actual, expected)
|
|
219
|
+
"""
|
|
220
|
+
actual_dicts = [_row_to_dict(r) if not isinstance(r, dict) else r for r in actual]
|
|
221
|
+
expected_dicts = [
|
|
222
|
+
_row_to_dict(r) if not isinstance(r, dict) else r for r in expected
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
if len(actual_dicts) != len(expected_dicts):
|
|
226
|
+
raise AssertionError(
|
|
227
|
+
f"Row count mismatch: got {len(actual_dicts)}, expected {len(expected_dicts)}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if not order_matters:
|
|
231
|
+
actual_dicts = _sort_rows_by_key(actual_dicts)
|
|
232
|
+
expected_dicts = _sort_rows_by_key(expected_dicts)
|
|
233
|
+
|
|
234
|
+
for i, (actual_row, expected_row) in enumerate(zip(actual_dicts, expected_dicts)):
|
|
235
|
+
errors = _compare_row_dicts(actual_row, expected_row, tolerance, i)
|
|
236
|
+
if errors:
|
|
237
|
+
raise AssertionError(errors[0])
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _row_to_dict(row: Any) -> Dict[str, Any]:
|
|
241
|
+
"""Convert a Row object to a plain Python dict.
|
|
242
|
+
|
|
243
|
+
Handles PySpark Row.asDict(), sparkless rows, and dict-like objects.
|
|
244
|
+
"""
|
|
245
|
+
if isinstance(row, dict):
|
|
246
|
+
d = row
|
|
247
|
+
elif hasattr(row, "asDict"):
|
|
248
|
+
d = row.asDict()
|
|
249
|
+
else:
|
|
250
|
+
d = dict(row)
|
|
251
|
+
|
|
252
|
+
out: Dict[str, Any] = {}
|
|
253
|
+
for k, v in d.items():
|
|
254
|
+
if (
|
|
255
|
+
v is not None
|
|
256
|
+
and hasattr(v, "__iter__")
|
|
257
|
+
and not isinstance(v, (str, bytes, dict))
|
|
258
|
+
):
|
|
259
|
+
try:
|
|
260
|
+
out[k] = list(v)
|
|
261
|
+
except (TypeError, ValueError):
|
|
262
|
+
out[k] = v
|
|
263
|
+
else:
|
|
264
|
+
out[k] = v
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _compare_row_dicts(
|
|
269
|
+
actual: Dict[str, Any],
|
|
270
|
+
expected: Dict[str, Any],
|
|
271
|
+
tolerance: float,
|
|
272
|
+
index: int,
|
|
273
|
+
) -> List[str]:
|
|
274
|
+
"""Compare two row dicts and return list of error messages."""
|
|
275
|
+
errors: List[str] = []
|
|
276
|
+
keys = set(actual.keys()) | set(expected.keys())
|
|
277
|
+
|
|
278
|
+
for key in sorted(keys):
|
|
279
|
+
if key not in actual:
|
|
280
|
+
errors.append(f"Row {index}: missing key '{key}' in actual")
|
|
281
|
+
continue
|
|
282
|
+
if key not in expected:
|
|
283
|
+
errors.append(f"Row {index}: extra key '{key}' in actual")
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
a, e = actual[key], expected[key]
|
|
287
|
+
if not _values_equal(a, e, tolerance):
|
|
288
|
+
errors.append(f"Row {index} key '{key}': {a!r} != {e!r}")
|
|
289
|
+
|
|
290
|
+
return errors
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _values_equal(actual: Any, expected: Any, tolerance: float = 1e-6) -> bool:
|
|
294
|
+
"""Compare two values with appropriate handling for different types."""
|
|
295
|
+
# Handle None
|
|
296
|
+
if actual is None and expected is None:
|
|
297
|
+
return True
|
|
298
|
+
if actual is None or expected is None:
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
# Handle NaN
|
|
302
|
+
if isinstance(actual, float) and isinstance(expected, float):
|
|
303
|
+
if math.isnan(actual) and math.isnan(expected):
|
|
304
|
+
return True
|
|
305
|
+
return math.isclose(actual, expected, abs_tol=tolerance)
|
|
306
|
+
|
|
307
|
+
# Handle dates and datetimes
|
|
308
|
+
if _is_date_like(actual) or _is_date_like(expected):
|
|
309
|
+
actual_normalized = _normalize_date_like(actual)
|
|
310
|
+
expected_normalized = _normalize_date_like(expected)
|
|
311
|
+
return actual_normalized == expected_normalized
|
|
312
|
+
|
|
313
|
+
# Handle lists
|
|
314
|
+
if isinstance(actual, list) and isinstance(expected, list):
|
|
315
|
+
if len(actual) != len(expected):
|
|
316
|
+
return False
|
|
317
|
+
return all(_values_equal(a, e, tolerance) for a, e in zip(actual, expected))
|
|
318
|
+
|
|
319
|
+
# Handle dicts
|
|
320
|
+
if isinstance(actual, dict) and isinstance(expected, dict):
|
|
321
|
+
if set(actual.keys()) != set(expected.keys()):
|
|
322
|
+
return False
|
|
323
|
+
return all(
|
|
324
|
+
_values_equal(actual[k], expected[k], tolerance) for k in actual.keys()
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Default comparison
|
|
328
|
+
return bool(actual == expected)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _is_date_like(value: Any) -> bool:
|
|
332
|
+
"""Check if a value is date-like."""
|
|
333
|
+
if isinstance(value, (date, datetime)):
|
|
334
|
+
return True
|
|
335
|
+
if isinstance(value, str) and len(value) >= 10:
|
|
336
|
+
if value[4:5] == "-" and value[7:8] == "-":
|
|
337
|
+
return (
|
|
338
|
+
value[:4].isdigit() and value[5:7].isdigit() and value[8:10].isdigit()
|
|
339
|
+
)
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _normalize_date_like(value: Any) -> Tuple[Optional[str], Optional[str]]:
|
|
344
|
+
"""Normalize a date-like value to (date_part, time_part) for comparison."""
|
|
345
|
+
if isinstance(value, datetime):
|
|
346
|
+
return (value.date().isoformat(), value.time().isoformat())
|
|
347
|
+
if isinstance(value, date):
|
|
348
|
+
return (value.isoformat(), None)
|
|
349
|
+
if isinstance(value, str) and _is_date_like(value):
|
|
350
|
+
if "T" in value or " " in value:
|
|
351
|
+
parts = value.replace("T", " ").split(" ", 1)
|
|
352
|
+
return (parts[0], parts[1] if len(parts) > 1 else None)
|
|
353
|
+
return (value[:10], None)
|
|
354
|
+
return (None, None)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _sort_rows_by_key(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
358
|
+
"""Sort rows by a canonical key for order-independent comparison."""
|
|
359
|
+
|
|
360
|
+
def key_fn(row: Dict[str, Any]) -> str:
|
|
361
|
+
return str(sorted((k, _sort_key_value(v)) for k, v in row.items()))
|
|
362
|
+
|
|
363
|
+
return sorted(rows, key=key_fn)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _sort_key_value(value: Any) -> Tuple[int, str]:
|
|
367
|
+
"""Convert value to a sortable tuple (avoids str vs int TypeError)."""
|
|
368
|
+
if value is None:
|
|
369
|
+
return (0, "")
|
|
370
|
+
return (1, f"{type(value).__name__}:{repr(value)}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _compare_schemas(actual_schema: Any, expected_schema: Any) -> bool:
|
|
374
|
+
"""Compare two schemas for equality."""
|
|
375
|
+
try:
|
|
376
|
+
actual_fields = actual_schema.fields if hasattr(actual_schema, "fields") else []
|
|
377
|
+
expected_fields = (
|
|
378
|
+
expected_schema.fields if hasattr(expected_schema, "fields") else []
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if len(actual_fields) != len(expected_fields):
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
for actual_field, expected_field in zip(actual_fields, expected_fields):
|
|
385
|
+
actual_name = getattr(actual_field, "name", None)
|
|
386
|
+
expected_name = getattr(expected_field, "name", None)
|
|
387
|
+
if actual_name != expected_name:
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
actual_type = getattr(actual_field, "dataType", None)
|
|
391
|
+
expected_type = getattr(expected_field, "dataType", None)
|
|
392
|
+
if not _compare_types(actual_type, expected_type):
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
return True
|
|
396
|
+
except Exception:
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _compare_types(actual_type: Any, expected_type: Any) -> bool:
|
|
401
|
+
"""Compare two data types for equality."""
|
|
402
|
+
if actual_type is None and expected_type is None:
|
|
403
|
+
return True
|
|
404
|
+
if actual_type is None or expected_type is None:
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
actual_name = actual_type.__class__.__name__
|
|
408
|
+
expected_name = expected_type.__class__.__name__
|
|
409
|
+
return bool(actual_name == expected_name)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""PySpark compatibility profile helpers for dual-backend testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
ENV_COMPAT = "SPARKLESS_PYSPARK_COMPAT"
|
|
8
|
+
DEFAULT_COMPAT = "3.5"
|
|
9
|
+
VALID_PROFILES = frozenset({"3.5", "4.0"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_compat_profile() -> str:
|
|
13
|
+
"""Return active compat profile from ``SPARKLESS_PYSPARK_COMPAT`` (default ``3.5``)."""
|
|
14
|
+
raw = os.environ.get(ENV_COMPAT, DEFAULT_COMPAT).strip()
|
|
15
|
+
if raw in VALID_PROFILES:
|
|
16
|
+
return raw
|
|
17
|
+
return DEFAULT_COMPAT
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def apply_compat_to_session(spark: object, profile: str | None = None) -> None:
|
|
21
|
+
"""Set ``sparkless.pyspark.compat`` on a SparkSession (sparkless backend)."""
|
|
22
|
+
compat = profile or get_compat_profile()
|
|
23
|
+
if compat not in VALID_PROFILES:
|
|
24
|
+
compat = DEFAULT_COMPAT
|
|
25
|
+
conf = getattr(spark, "conf", None)
|
|
26
|
+
if conf is None:
|
|
27
|
+
return
|
|
28
|
+
conf.set("sparkless.pyspark.compat", compat)
|