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.
Files changed (39) hide show
  1. sparkless_testing-4.19.0/PKG-INFO +27 -0
  2. sparkless_testing-4.19.0/README.md +15 -0
  3. sparkless_testing-4.19.0/pyproject.toml +23 -0
  4. sparkless_testing-4.19.0/sparkless_testing/__init__.py +120 -0
  5. sparkless_testing-4.19.0/sparkless_testing/comparison.py +409 -0
  6. sparkless_testing-4.19.0/sparkless_testing/compat.py +28 -0
  7. sparkless_testing-4.19.0/sparkless_testing/fixtures.py +265 -0
  8. sparkless_testing-4.19.0/sparkless_testing/imports.py +214 -0
  9. sparkless_testing-4.19.0/sparkless_testing/jdbc.py +311 -0
  10. sparkless_testing-4.19.0/sparkless_testing/mode.py +120 -0
  11. sparkless_testing-4.19.0/sparkless_testing/plugin.py +126 -0
  12. sparkless_testing-4.19.0/sparkless_testing/session.py +532 -0
  13. sparkless_testing-4.19.0/sparkless_testing.egg-info/PKG-INFO +27 -0
  14. sparkless_testing-4.19.0/sparkless_testing.egg-info/SOURCES.txt +17 -0
  15. sparkless_testing-4.19.0/sparkless_testing.egg-info/requires.txt +1 -0
  16. sparkless_testing-0.2.0/LICENSE +0 -22
  17. sparkless_testing-0.2.0/PKG-INFO +0 -423
  18. sparkless_testing-0.2.0/README.md +0 -385
  19. sparkless_testing-0.2.0/pyproject.toml +0 -138
  20. sparkless_testing-0.2.0/sparkless_testing/__init__.py +0 -87
  21. sparkless_testing-0.2.0/sparkless_testing/conftest.py +0 -39
  22. sparkless_testing-0.2.0/sparkless_testing/engine.py +0 -306
  23. sparkless_testing-0.2.0/sparkless_testing/parametrize.py +0 -149
  24. sparkless_testing-0.2.0/sparkless_testing/protocols.py +0 -145
  25. sparkless_testing-0.2.0/sparkless_testing/pytest_fixtures.py +0 -233
  26. sparkless_testing-0.2.0/sparkless_testing/session.py +0 -244
  27. sparkless_testing-0.2.0/sparkless_testing/utils.py +0 -209
  28. sparkless_testing-0.2.0/sparkless_testing.egg-info/PKG-INFO +0 -423
  29. sparkless_testing-0.2.0/sparkless_testing.egg-info/SOURCES.txt +0 -22
  30. sparkless_testing-0.2.0/sparkless_testing.egg-info/requires.txt +0 -15
  31. sparkless_testing-0.2.0/tests/test_engine.py +0 -389
  32. sparkless_testing-0.2.0/tests/test_fixtures.py +0 -344
  33. sparkless_testing-0.2.0/tests/test_parametrize.py +0 -150
  34. sparkless_testing-0.2.0/tests/test_session.py +0 -666
  35. sparkless_testing-0.2.0/tests/test_utils.py +0 -428
  36. {sparkless_testing-0.2.0 → sparkless_testing-4.19.0}/setup.cfg +0 -0
  37. {sparkless_testing-0.2.0 → sparkless_testing-4.19.0}/sparkless_testing/py.typed +0 -0
  38. {sparkless_testing-0.2.0 → sparkless_testing-4.19.0}/sparkless_testing.egg-info/dependency_links.txt +0 -0
  39. {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)