streamcase 0.1.0a1__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.
- streamcase/__init__.py +23 -0
- streamcase/_cleanup.py +33 -0
- streamcase/_diagnostics.py +108 -0
- streamcase/_directories.py +107 -0
- streamcase/_input_files.py +59 -0
- streamcase/_serialization.py +31 -0
- streamcase/_spark_capture.py +33 -0
- streamcase/_spark_execution.py +95 -0
- streamcase/_spark_lifecycle.py +81 -0
- streamcase/_spark_managed.py +43 -0
- streamcase/_spark_query_options.py +67 -0
- streamcase/_spark_source.py +90 -0
- streamcase/_version.py +3 -0
- streamcase/actions.py +51 -0
- streamcase/assertions.py +153 -0
- streamcase/py.typed +1 -0
- streamcase/results.py +91 -0
- streamcase/scenario.py +58 -0
- streamcase/spark.py +93 -0
- streamcase-0.1.0a1.dist-info/METADATA +146 -0
- streamcase-0.1.0a1.dist-info/RECORD +24 -0
- streamcase-0.1.0a1.dist-info/WHEEL +4 -0
- streamcase-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- streamcase-0.1.0a1.dist-info/licenses/NOTICE +9 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Private construction of the runner-owned Spark JSON input source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from streamcase._directories import RunDirectories
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pyspark.sql import DataFrame, SparkSession
|
|
12
|
+
from pyspark.sql.types import StructType
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_RESERVED_SOURCE_OPTIONS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"badrecordspath",
|
|
18
|
+
"basepath",
|
|
19
|
+
"cleansource",
|
|
20
|
+
"columnnameofcorruptrecord",
|
|
21
|
+
"discardcachedinputratio",
|
|
22
|
+
"encoding",
|
|
23
|
+
"filenameonly",
|
|
24
|
+
"ignorecorruptfiles",
|
|
25
|
+
"ignoremissingfiles",
|
|
26
|
+
"inferschema",
|
|
27
|
+
"latestfirst",
|
|
28
|
+
"linesep",
|
|
29
|
+
"maxbytespertrigger",
|
|
30
|
+
"maxcachedfiles",
|
|
31
|
+
"maxfileage",
|
|
32
|
+
"maxfilespertrigger",
|
|
33
|
+
"mode",
|
|
34
|
+
"modifiedafter",
|
|
35
|
+
"modifiedbefore",
|
|
36
|
+
"multiline",
|
|
37
|
+
"path",
|
|
38
|
+
"pathglobfilter",
|
|
39
|
+
"paths",
|
|
40
|
+
"recursivefilelookup",
|
|
41
|
+
"samplingratio",
|
|
42
|
+
"schema",
|
|
43
|
+
"sourcearchivedir",
|
|
44
|
+
"wholefile",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _copy_source_options(source_options: Mapping[str, str] | None) -> dict[str, str]:
|
|
50
|
+
copied = {} if source_options is None else dict(source_options)
|
|
51
|
+
conflicts: list[str] = []
|
|
52
|
+
|
|
53
|
+
for name, value in copied.items():
|
|
54
|
+
if not isinstance(name, str):
|
|
55
|
+
raise TypeError("Source option names must be strings.")
|
|
56
|
+
if not isinstance(value, str):
|
|
57
|
+
raise TypeError(f"Source option {name!r} must have a string value.")
|
|
58
|
+
if name.casefold() in _RESERVED_SOURCE_OPTIONS:
|
|
59
|
+
conflicts.append(name)
|
|
60
|
+
|
|
61
|
+
if conflicts:
|
|
62
|
+
rendered = ", ".join(repr(name) for name in sorted(conflicts, key=str.casefold))
|
|
63
|
+
raise ValueError(f"Runner-owned source options cannot be overridden: {rendered}.")
|
|
64
|
+
|
|
65
|
+
return copied
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_json_stream(
|
|
69
|
+
spark: SparkSession,
|
|
70
|
+
directories: RunDirectories,
|
|
71
|
+
*,
|
|
72
|
+
schema: StructType | str,
|
|
73
|
+
source_options: Mapping[str, str] | None = None,
|
|
74
|
+
) -> DataFrame:
|
|
75
|
+
"""Build a deterministic streaming JSON source without starting a query."""
|
|
76
|
+
if schema is None:
|
|
77
|
+
raise ValueError("An explicit Spark input schema is required.")
|
|
78
|
+
if isinstance(schema, str) and not schema.strip():
|
|
79
|
+
raise ValueError("The Spark input schema must not be empty.")
|
|
80
|
+
|
|
81
|
+
approved_options = _copy_source_options(source_options)
|
|
82
|
+
reader = (
|
|
83
|
+
spark.readStream.schema(schema)
|
|
84
|
+
.option("maxFilesPerTrigger", 1)
|
|
85
|
+
.option("multiLine", "false")
|
|
86
|
+
.option("mode", "FAILFAST")
|
|
87
|
+
.options(**approved_options)
|
|
88
|
+
)
|
|
89
|
+
stream: DataFrame = reader.json(str(directories.input_dir))
|
|
90
|
+
return stream
|
streamcase/_version.py
ADDED
streamcase/actions.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Scenario actions for describing streaming test inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
11
|
+
class Batch:
|
|
12
|
+
"""One non-empty logical batch of input rows.
|
|
13
|
+
|
|
14
|
+
Row mappings are copied when the batch is created and exposed as read-only
|
|
15
|
+
mappings. Every row key must be a string. Values inside a row are not
|
|
16
|
+
recursively copied or frozen.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
rows: tuple[Mapping[str, object], ...]
|
|
20
|
+
|
|
21
|
+
def __init__(self, rows: Iterable[Mapping[str, object]]) -> None:
|
|
22
|
+
"""Create a batch from rows while preserving their insertion order."""
|
|
23
|
+
snapshots = tuple(MappingProxyType(dict(row)) for row in rows)
|
|
24
|
+
if not snapshots:
|
|
25
|
+
raise ValueError("Batch must contain at least one row.")
|
|
26
|
+
|
|
27
|
+
for row_index, row in enumerate(snapshots):
|
|
28
|
+
for key in row:
|
|
29
|
+
if not isinstance(key, str):
|
|
30
|
+
message = (
|
|
31
|
+
f"Batch row at index {row_index} contains a non-string key "
|
|
32
|
+
f"of type {type(key).__name__}."
|
|
33
|
+
)
|
|
34
|
+
raise TypeError(message)
|
|
35
|
+
|
|
36
|
+
object.__setattr__(self, "rows", snapshots)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class Restart:
|
|
41
|
+
"""A checkpoint-preserving streaming-query restart boundary."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def batch(*rows: Mapping[str, object]) -> Batch:
|
|
45
|
+
"""Create a :class:`Batch` from one or more input rows."""
|
|
46
|
+
return Batch(rows)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def restart() -> Restart:
|
|
50
|
+
"""Create a checkpoint-preserving restart action."""
|
|
51
|
+
return Restart()
|
streamcase/assertions.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Backend-independent assertions for captured streaming results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from streamcase._diagnostics import (
|
|
10
|
+
_format_batch_count_mismatch,
|
|
11
|
+
_format_duplicate_keys,
|
|
12
|
+
_format_missing_key_fields,
|
|
13
|
+
_format_rows_mismatch,
|
|
14
|
+
)
|
|
15
|
+
from streamcase.results import CapturedBatch, ScenarioResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def assert_batch_count(result: ScenarioResult, expected_count: int) -> None:
|
|
19
|
+
"""Assert the exact number of captured output batches."""
|
|
20
|
+
if not isinstance(result, ScenarioResult):
|
|
21
|
+
message = f"result must be a ScenarioResult; got {type(result).__name__}."
|
|
22
|
+
raise TypeError(message)
|
|
23
|
+
if isinstance(expected_count, bool) or not isinstance(expected_count, int):
|
|
24
|
+
message = f"expected_count must be an int; got {type(expected_count).__name__}."
|
|
25
|
+
raise TypeError(message)
|
|
26
|
+
if expected_count < 0:
|
|
27
|
+
raise ValueError("expected_count must be non-negative.")
|
|
28
|
+
|
|
29
|
+
actual_count = len(result.batches)
|
|
30
|
+
if actual_count != expected_count:
|
|
31
|
+
raise AssertionError(_format_batch_count_mismatch(expected_count, actual_count))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _values_equal(left: object, right: object) -> bool:
|
|
35
|
+
if type(left) is not type(right):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
if isinstance(left, float):
|
|
39
|
+
right_float = cast(float, right)
|
|
40
|
+
if math.isnan(left) and math.isnan(right_float):
|
|
41
|
+
return True
|
|
42
|
+
return left == right_float
|
|
43
|
+
|
|
44
|
+
if isinstance(left, Mapping):
|
|
45
|
+
right_mapping = cast(Mapping[object, object], right)
|
|
46
|
+
if set(left) != set(right_mapping):
|
|
47
|
+
return False
|
|
48
|
+
for key, left_value in left.items():
|
|
49
|
+
if not _values_equal(left_value, right_mapping[key]):
|
|
50
|
+
return False
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
if isinstance(left, tuple):
|
|
54
|
+
right_tuple = cast(tuple[object, ...], right)
|
|
55
|
+
if len(left) != len(right_tuple):
|
|
56
|
+
return False
|
|
57
|
+
for left_value, right_value in zip(left, right_tuple, strict=True):
|
|
58
|
+
if not _values_equal(left_value, right_value):
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
return left == right
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def assert_unique_keys(result: ScenarioResult, *fields: str) -> None:
|
|
66
|
+
"""Assert that selected fields form unique keys across all result rows."""
|
|
67
|
+
if not isinstance(result, ScenarioResult):
|
|
68
|
+
message = f"result must be a ScenarioResult; got {type(result).__name__}."
|
|
69
|
+
raise TypeError(message)
|
|
70
|
+
if not fields:
|
|
71
|
+
raise ValueError("At least one key field is required.")
|
|
72
|
+
|
|
73
|
+
seen_fields: set[str] = set()
|
|
74
|
+
for index, field in enumerate(fields):
|
|
75
|
+
if not isinstance(field, str):
|
|
76
|
+
message = f"Key field at index {index} must be a string; got {type(field).__name__}."
|
|
77
|
+
raise TypeError(message)
|
|
78
|
+
if field in seen_fields:
|
|
79
|
+
raise ValueError(f"Key fields must be unique; repeated {field!r}.")
|
|
80
|
+
seen_fields.add(field)
|
|
81
|
+
|
|
82
|
+
groups: list[tuple[tuple[object, ...], list[tuple[int, int]]]] = []
|
|
83
|
+
for captured_batch in result.batches:
|
|
84
|
+
for row_index, row in enumerate(captured_batch.rows):
|
|
85
|
+
missing_fields = tuple(field for field in fields if field not in row)
|
|
86
|
+
if missing_fields:
|
|
87
|
+
raise AssertionError(
|
|
88
|
+
_format_missing_key_fields(
|
|
89
|
+
captured_batch.batch_id,
|
|
90
|
+
row_index,
|
|
91
|
+
missing_fields,
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
key = tuple(row[field] for field in fields)
|
|
96
|
+
matched_locations: list[tuple[int, int]] | None = None
|
|
97
|
+
for existing_key, locations in groups:
|
|
98
|
+
if _values_equal(key, existing_key):
|
|
99
|
+
matched_locations = locations
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
location = (captured_batch.batch_id, row_index)
|
|
103
|
+
if matched_locations is None:
|
|
104
|
+
groups.append((key, [location]))
|
|
105
|
+
else:
|
|
106
|
+
matched_locations.append(location)
|
|
107
|
+
|
|
108
|
+
duplicates: list[tuple[tuple[str, ...], tuple[object, ...], tuple[tuple[int, int], ...]]] = []
|
|
109
|
+
for key, locations in groups:
|
|
110
|
+
if len(locations) < 2:
|
|
111
|
+
continue
|
|
112
|
+
duplicates.append((fields, key, tuple(locations)))
|
|
113
|
+
|
|
114
|
+
if duplicates:
|
|
115
|
+
raise AssertionError(_format_duplicate_keys(duplicates))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def assert_rows_equal(
|
|
119
|
+
result: ScenarioResult,
|
|
120
|
+
expected_rows: Iterable[Mapping[str, object]],
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Assert duplicate-aware equality of unordered flattened result rows."""
|
|
123
|
+
if not isinstance(result, ScenarioResult):
|
|
124
|
+
message = f"result must be a ScenarioResult; got {type(result).__name__}."
|
|
125
|
+
raise TypeError(message)
|
|
126
|
+
|
|
127
|
+
expected = CapturedBatch(0, expected_rows).rows
|
|
128
|
+
unmatched_actual = list(result.rows)
|
|
129
|
+
missing: list[Mapping[str, object]] = []
|
|
130
|
+
|
|
131
|
+
for expected_row in expected:
|
|
132
|
+
matched_index: int | None = None
|
|
133
|
+
for index, actual_row in enumerate(unmatched_actual):
|
|
134
|
+
if _values_equal(expected_row, actual_row):
|
|
135
|
+
matched_index = index
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
if matched_index is None:
|
|
139
|
+
missing.append(expected_row)
|
|
140
|
+
else:
|
|
141
|
+
unmatched_actual.pop(matched_index)
|
|
142
|
+
|
|
143
|
+
if not missing and not unmatched_actual:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
raise AssertionError(
|
|
147
|
+
_format_rows_mismatch(
|
|
148
|
+
len(expected),
|
|
149
|
+
len(result.rows),
|
|
150
|
+
missing,
|
|
151
|
+
unmatched_actual,
|
|
152
|
+
),
|
|
153
|
+
)
|
streamcase/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
streamcase/results.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Immutable backend-independent captured result models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _freeze_mapping(value: Mapping[object, object], path: str) -> Mapping[str, object]:
|
|
13
|
+
frozen: dict[str, object] = {}
|
|
14
|
+
for key, nested_value in value.items():
|
|
15
|
+
if not isinstance(key, str):
|
|
16
|
+
message = f"Captured mapping at {path} must use string keys; got {type(key).__name__}."
|
|
17
|
+
raise TypeError(message)
|
|
18
|
+
frozen[key] = _freeze_value(nested_value, f"{path}.{key}")
|
|
19
|
+
return MappingProxyType(frozen)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _freeze_value(value: object, path: str) -> object:
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
if isinstance(value, (bool, int, float, str, bytes, Decimal, date)):
|
|
26
|
+
return value
|
|
27
|
+
if isinstance(value, Mapping):
|
|
28
|
+
return _freeze_mapping(value, path)
|
|
29
|
+
if isinstance(value, (list, tuple)):
|
|
30
|
+
return tuple(
|
|
31
|
+
_freeze_value(nested_value, f"{path}[{index}]")
|
|
32
|
+
for index, nested_value in enumerate(value)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
message = f"Captured value at {path} has unsupported type {type(value).__name__}."
|
|
36
|
+
raise TypeError(message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _freeze_row(row: object, row_index: int) -> Mapping[str, object]:
|
|
40
|
+
if not isinstance(row, Mapping):
|
|
41
|
+
message = f"Captured row at index {row_index} must be a mapping; got {type(row).__name__}."
|
|
42
|
+
raise TypeError(message)
|
|
43
|
+
return _freeze_mapping(row, f"row[{row_index}]")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
47
|
+
class CapturedBatch:
|
|
48
|
+
"""One immutable output batch captured independently of a streaming backend."""
|
|
49
|
+
|
|
50
|
+
batch_id: int
|
|
51
|
+
rows: tuple[Mapping[str, object], ...]
|
|
52
|
+
|
|
53
|
+
def __init__(self, batch_id: int, rows: Iterable[Mapping[str, object]]) -> None:
|
|
54
|
+
"""Snapshot an output batch and recursively freeze its rows."""
|
|
55
|
+
if isinstance(batch_id, bool) or not isinstance(batch_id, int):
|
|
56
|
+
message = f"Captured batch ID must be an int; got {type(batch_id).__name__}."
|
|
57
|
+
raise TypeError(message)
|
|
58
|
+
if batch_id < 0:
|
|
59
|
+
raise ValueError("Captured batch ID must be non-negative.")
|
|
60
|
+
|
|
61
|
+
snapshots = tuple(_freeze_row(row, index) for index, row in enumerate(rows))
|
|
62
|
+
object.__setattr__(self, "batch_id", batch_id)
|
|
63
|
+
object.__setattr__(self, "rows", snapshots)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
67
|
+
class ScenarioResult:
|
|
68
|
+
"""An immutable ordered snapshot of captured output batches."""
|
|
69
|
+
|
|
70
|
+
batches: tuple[CapturedBatch, ...]
|
|
71
|
+
_rows: tuple[Mapping[str, object], ...] = field(repr=False, compare=False)
|
|
72
|
+
|
|
73
|
+
def __init__(self, batches: Iterable[CapturedBatch]) -> None:
|
|
74
|
+
"""Snapshot captured batches and expose their rows in execution order."""
|
|
75
|
+
snapshot = tuple(batches)
|
|
76
|
+
for index, captured_batch in enumerate(snapshot):
|
|
77
|
+
if not isinstance(captured_batch, CapturedBatch):
|
|
78
|
+
message = (
|
|
79
|
+
f"Scenario result batch at index {index} must be a CapturedBatch; "
|
|
80
|
+
f"got {type(captured_batch).__name__}."
|
|
81
|
+
)
|
|
82
|
+
raise TypeError(message)
|
|
83
|
+
|
|
84
|
+
rows = tuple(row for captured_batch in snapshot for row in captured_batch.rows)
|
|
85
|
+
object.__setattr__(self, "batches", snapshot)
|
|
86
|
+
object.__setattr__(self, "_rows", rows)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def rows(self) -> tuple[Mapping[str, object], ...]:
|
|
90
|
+
"""Return captured rows flattened in batch and row order."""
|
|
91
|
+
return self._rows
|
streamcase/scenario.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Immutable scenario model for ordered streaming-test actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TypeAlias
|
|
8
|
+
|
|
9
|
+
from streamcase.actions import Batch, Restart
|
|
10
|
+
|
|
11
|
+
Action: TypeAlias = Batch | Restart
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True, init=False)
|
|
15
|
+
class Scenario:
|
|
16
|
+
"""A non-empty, ordered sequence of supported scenario actions.
|
|
17
|
+
|
|
18
|
+
Restart actions must appear between batch actions and cannot be consecutive.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
actions: tuple[Action, ...]
|
|
22
|
+
|
|
23
|
+
def __init__(self, actions: Iterable[Action]) -> None:
|
|
24
|
+
"""Snapshot and validate an ordered iterable of actions."""
|
|
25
|
+
snapshot = tuple(actions)
|
|
26
|
+
if not snapshot:
|
|
27
|
+
raise ValueError("Scenario must contain at least one action.")
|
|
28
|
+
|
|
29
|
+
for index, action in enumerate(snapshot):
|
|
30
|
+
if not isinstance(action, (Batch, Restart)):
|
|
31
|
+
message = (
|
|
32
|
+
f"Scenario action at index {index} must be a Batch or Restart; "
|
|
33
|
+
f"got {type(action).__name__}."
|
|
34
|
+
)
|
|
35
|
+
raise TypeError(message)
|
|
36
|
+
|
|
37
|
+
if isinstance(snapshot[0], Restart):
|
|
38
|
+
raise ValueError("Scenario Restart action at index 0 cannot be first.")
|
|
39
|
+
|
|
40
|
+
last_index = len(snapshot) - 1
|
|
41
|
+
if isinstance(snapshot[last_index], Restart):
|
|
42
|
+
message = f"Scenario Restart action at index {last_index} cannot be last."
|
|
43
|
+
raise ValueError(message)
|
|
44
|
+
|
|
45
|
+
for index in range(1, len(snapshot)):
|
|
46
|
+
if isinstance(snapshot[index - 1], Restart) and isinstance(snapshot[index], Restart):
|
|
47
|
+
message = (
|
|
48
|
+
f"Scenario Restart action at index {index} cannot immediately "
|
|
49
|
+
"follow another Restart."
|
|
50
|
+
)
|
|
51
|
+
raise ValueError(message)
|
|
52
|
+
|
|
53
|
+
object.__setattr__(self, "actions", snapshot)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def scenario(*actions: Action) -> Scenario:
|
|
57
|
+
"""Create a :class:`Scenario` from one or more ordered actions."""
|
|
58
|
+
return Scenario(actions)
|
streamcase/spark.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Public Apache Spark Structured Streaming runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from pyspark.sql import DataFrame, SparkSession
|
|
11
|
+
from pyspark.sql.types import StructType
|
|
12
|
+
except ModuleNotFoundError as error:
|
|
13
|
+
if (error.name or "").partition(".")[0] != "pyspark":
|
|
14
|
+
raise
|
|
15
|
+
raise ImportError(
|
|
16
|
+
'PySpark is required for streamcase.spark; install "streamcase[spark]".'
|
|
17
|
+
) from error
|
|
18
|
+
|
|
19
|
+
from streamcase._directories import RunDirectories
|
|
20
|
+
from streamcase._spark_managed import _run_managed_batches
|
|
21
|
+
from streamcase._spark_query_options import _prepare_query_configuration
|
|
22
|
+
from streamcase._spark_source import _build_json_stream, _copy_source_options
|
|
23
|
+
from streamcase.results import ScenarioResult
|
|
24
|
+
from streamcase.scenario import Scenario
|
|
25
|
+
|
|
26
|
+
__all__ = ["run_scenario"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_output_frame(output: object, spark: SparkSession) -> DataFrame:
|
|
30
|
+
if not isinstance(output, DataFrame):
|
|
31
|
+
raise TypeError("Spark transformation must return a DataFrame.")
|
|
32
|
+
if not output.isStreaming:
|
|
33
|
+
raise ValueError("Spark transformation must return a streaming DataFrame.")
|
|
34
|
+
if output.sparkSession is not spark:
|
|
35
|
+
raise ValueError("Spark transformation output must belong to the supplied session.")
|
|
36
|
+
|
|
37
|
+
seen: set[str] = set()
|
|
38
|
+
duplicates: set[str] = set()
|
|
39
|
+
for name in output.columns:
|
|
40
|
+
normalized = name.casefold()
|
|
41
|
+
if normalized in seen:
|
|
42
|
+
duplicates.add(name)
|
|
43
|
+
seen.add(normalized)
|
|
44
|
+
if duplicates:
|
|
45
|
+
rendered = ", ".join(repr(name) for name in sorted(duplicates, key=str.casefold))
|
|
46
|
+
raise ValueError(f"Spark transformation output has duplicate column names: {rendered}.")
|
|
47
|
+
return output
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_scenario(
|
|
51
|
+
spark: SparkSession,
|
|
52
|
+
scenario: Scenario,
|
|
53
|
+
*,
|
|
54
|
+
schema: StructType | str,
|
|
55
|
+
transform: Callable[[DataFrame], DataFrame],
|
|
56
|
+
output_mode: Literal["append", "complete", "update"] = "append",
|
|
57
|
+
source_options: Mapping[str, str] | None = None,
|
|
58
|
+
query_options: Mapping[str, str] | None = None,
|
|
59
|
+
base_dir: str | os.PathLike[str] | None = None,
|
|
60
|
+
retain_artifacts: bool = False,
|
|
61
|
+
) -> ScenarioResult:
|
|
62
|
+
"""Run a scenario using a caller-owned Spark session."""
|
|
63
|
+
if not isinstance(spark, SparkSession):
|
|
64
|
+
raise TypeError("spark must be a PySpark SparkSession.")
|
|
65
|
+
if not isinstance(scenario, Scenario):
|
|
66
|
+
raise TypeError("scenario must be a Streamcase Scenario.")
|
|
67
|
+
if not isinstance(schema, (StructType, str)):
|
|
68
|
+
raise TypeError("schema must be a Spark StructType or DDL string.")
|
|
69
|
+
if isinstance(schema, str) and not schema.strip():
|
|
70
|
+
raise ValueError("The Spark input schema must not be empty.")
|
|
71
|
+
if not callable(transform):
|
|
72
|
+
raise TypeError("transform must be callable.")
|
|
73
|
+
|
|
74
|
+
approved_source_options = _copy_source_options(source_options)
|
|
75
|
+
approved_mode, approved_query_options = _prepare_query_configuration(output_mode, query_options)
|
|
76
|
+
|
|
77
|
+
def build_stream(directories: RunDirectories) -> DataFrame:
|
|
78
|
+
source = _build_json_stream(
|
|
79
|
+
spark,
|
|
80
|
+
directories,
|
|
81
|
+
schema=schema,
|
|
82
|
+
source_options=approved_source_options,
|
|
83
|
+
)
|
|
84
|
+
return _validate_output_frame(transform(source), spark)
|
|
85
|
+
|
|
86
|
+
return _run_managed_batches(
|
|
87
|
+
scenario,
|
|
88
|
+
build_stream,
|
|
89
|
+
base_dir=base_dir,
|
|
90
|
+
retain_artifacts=retain_artifacts,
|
|
91
|
+
output_mode=approved_mode,
|
|
92
|
+
query_options=approved_query_options,
|
|
93
|
+
)
|