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.
@@ -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
@@ -0,0 +1,3 @@
1
+ """Streamcase package version."""
2
+
3
+ __version__ = "0.1.0a1"
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()
@@ -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
+ )