sqllocks-shape-simulation 0.9.1__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.
- shape_simulation/__init__.py +157 -0
- shape_simulation/_patterns.py +321 -0
- shape_simulation/_tables.py +156 -0
- shape_simulation/cli.py +57 -0
- shape_simulation/cli_batch.py +421 -0
- shape_simulation/cli_patterns.py +221 -0
- shape_simulation/clickstream_patterns.py +442 -0
- shape_simulation/file_drop.py +453 -0
- shape_simulation/financial_patterns.py +445 -0
- shape_simulation/hybrid.py +192 -0
- shape_simulation/iot_patterns.py +430 -0
- shape_simulation/operational_log_patterns.py +542 -0
- shape_simulation/pulse_patterns.py +629 -0
- shape_simulation/scd2_file_drops.py +446 -0
- shape_simulation/state_machine.py +496 -0
- shape_simulation/stream_emit.py +495 -0
- sqllocks_shape_simulation-0.9.1.dist-info/METADATA +61 -0
- sqllocks_shape_simulation-0.9.1.dist-info/RECORD +22 -0
- sqllocks_shape_simulation-0.9.1.dist-info/WHEEL +5 -0
- sqllocks_shape_simulation-0.9.1.dist-info/entry_points.txt +2 -0
- sqllocks_shape_simulation-0.9.1.dist-info/licenses/LICENSE +21 -0
- sqllocks_shape_simulation-0.9.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Shape plugin: simulation scenarios (P6-04).
|
|
2
|
+
|
|
3
|
+
Each simulator lives in its own module and is also importable from here, lazily (importing the
|
|
4
|
+
package loads no simulator, and none of Arrow or NumPy):
|
|
5
|
+
|
|
6
|
+
* ``file_drop``: :class:`FileDropSimulator`, an upstream source landing files over a date range;
|
|
7
|
+
* ``scd2_file_drops``: :class:`SCD2FileDropSimulator`, a full load and daily versioned deltas;
|
|
8
|
+
* ``stream_emit``: :class:`StreamEmitter`, tables as enveloped events on the emit runtime;
|
|
9
|
+
* ``hybrid``: :class:`HybridSimulator`, a file drop and a stream of the same tables, linked;
|
|
10
|
+
* ``state_machine``: :class:`WorkflowSimulator`, business-process events with dwell times;
|
|
11
|
+
* ``clickstream_patterns``, ``financial_patterns``, ``iot_patterns``,
|
|
12
|
+
``operational_log_patterns``, ``pulse_patterns``: the pattern simulators (sessions and funnels,
|
|
13
|
+
reversals and fraud bursts, sensor drift and alert storms, service logs and traces, rideshare
|
|
14
|
+
telemetry and marts), which take a configuration and, where they layer on existing data, Arrow
|
|
15
|
+
tables, and return Arrow tables and summary statistics;
|
|
16
|
+
* ``cli``, ``cli_batch``, ``cli_patterns``: ``shape simulate``, the command that runs them.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import importlib
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
SHAPE_API = "1.0"
|
|
25
|
+
"""The plugin targets the plugin API major version this Shape provides."""
|
|
26
|
+
|
|
27
|
+
# name -> module that defines it (one line per name; each simulator adds its own lines)
|
|
28
|
+
_EXPORTS: dict[str, str] = {
|
|
29
|
+
"ClickstreamConfig": "shape_simulation.clickstream_patterns",
|
|
30
|
+
"ClickstreamResult": "shape_simulation.clickstream_patterns",
|
|
31
|
+
"ClickstreamSimulator": "shape_simulation.clickstream_patterns",
|
|
32
|
+
"FileDropConfig": "shape_simulation.file_drop",
|
|
33
|
+
"FileDropResult": "shape_simulation.file_drop",
|
|
34
|
+
"FileDropSimulator": "shape_simulation.file_drop",
|
|
35
|
+
"FinancialStreamConfig": "shape_simulation.financial_patterns",
|
|
36
|
+
"FinancialStreamResult": "shape_simulation.financial_patterns",
|
|
37
|
+
"FinancialStreamSimulator": "shape_simulation.financial_patterns",
|
|
38
|
+
"HybridConfig": "shape_simulation.hybrid",
|
|
39
|
+
"HybridResult": "shape_simulation.hybrid",
|
|
40
|
+
"HybridSimulator": "shape_simulation.hybrid",
|
|
41
|
+
"IoTTelemetryConfig": "shape_simulation.iot_patterns",
|
|
42
|
+
"IoTTelemetryResult": "shape_simulation.iot_patterns",
|
|
43
|
+
"IoTTelemetrySimulator": "shape_simulation.iot_patterns",
|
|
44
|
+
"OperationalLogConfig": "shape_simulation.operational_log_patterns",
|
|
45
|
+
"OperationalLogResult": "shape_simulation.operational_log_patterns",
|
|
46
|
+
"OperationalLogSimulator": "shape_simulation.operational_log_patterns",
|
|
47
|
+
"PulseDemandConfig": "shape_simulation.pulse_patterns",
|
|
48
|
+
"PulseDemandSimulator": "shape_simulation.pulse_patterns",
|
|
49
|
+
"PulseSimResult": "shape_simulation.pulse_patterns",
|
|
50
|
+
"SCD2FileDropConfig": "shape_simulation.scd2_file_drops",
|
|
51
|
+
"SCD2FileDropResult": "shape_simulation.scd2_file_drops",
|
|
52
|
+
"SCD2FileDropSimulator": "shape_simulation.scd2_file_drops",
|
|
53
|
+
"SimulateCommand": "shape_simulation.cli",
|
|
54
|
+
"StateDefinition": "shape_simulation.state_machine",
|
|
55
|
+
"StreamEmitConfig": "shape_simulation.stream_emit",
|
|
56
|
+
"StreamEmitResult": "shape_simulation.stream_emit",
|
|
57
|
+
"StreamEmitter": "shape_simulation.stream_emit",
|
|
58
|
+
"TransitionRule": "shape_simulation.state_machine",
|
|
59
|
+
"WorkflowConfig": "shape_simulation.state_machine",
|
|
60
|
+
"WorkflowResult": "shape_simulation.state_machine",
|
|
61
|
+
"WorkflowSimulator": "shape_simulation.state_machine",
|
|
62
|
+
"get_preset_workflow": "shape_simulation.state_machine",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"ClickstreamConfig",
|
|
67
|
+
"ClickstreamResult",
|
|
68
|
+
"ClickstreamSimulator",
|
|
69
|
+
"FileDropConfig",
|
|
70
|
+
"FileDropResult",
|
|
71
|
+
"FileDropSimulator",
|
|
72
|
+
"FinancialStreamConfig",
|
|
73
|
+
"FinancialStreamResult",
|
|
74
|
+
"FinancialStreamSimulator",
|
|
75
|
+
"HybridConfig",
|
|
76
|
+
"HybridResult",
|
|
77
|
+
"HybridSimulator",
|
|
78
|
+
"IoTTelemetryConfig",
|
|
79
|
+
"IoTTelemetryResult",
|
|
80
|
+
"IoTTelemetrySimulator",
|
|
81
|
+
"OperationalLogConfig",
|
|
82
|
+
"OperationalLogResult",
|
|
83
|
+
"OperationalLogSimulator",
|
|
84
|
+
"PulseDemandConfig",
|
|
85
|
+
"PulseDemandSimulator",
|
|
86
|
+
"PulseSimResult",
|
|
87
|
+
"SCD2FileDropConfig",
|
|
88
|
+
"SCD2FileDropResult",
|
|
89
|
+
"SCD2FileDropSimulator",
|
|
90
|
+
"SimulateCommand",
|
|
91
|
+
"StateDefinition",
|
|
92
|
+
"StreamEmitConfig",
|
|
93
|
+
"StreamEmitResult",
|
|
94
|
+
"StreamEmitter",
|
|
95
|
+
"TransitionRule",
|
|
96
|
+
"WorkflowConfig",
|
|
97
|
+
"WorkflowResult",
|
|
98
|
+
"WorkflowSimulator",
|
|
99
|
+
"get_preset_workflow",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
if TYPE_CHECKING:
|
|
103
|
+
from shape_simulation.cli import SimulateCommand
|
|
104
|
+
from shape_simulation.clickstream_patterns import (
|
|
105
|
+
ClickstreamConfig,
|
|
106
|
+
ClickstreamResult,
|
|
107
|
+
ClickstreamSimulator,
|
|
108
|
+
)
|
|
109
|
+
from shape_simulation.file_drop import FileDropConfig, FileDropResult, FileDropSimulator
|
|
110
|
+
from shape_simulation.financial_patterns import (
|
|
111
|
+
FinancialStreamConfig,
|
|
112
|
+
FinancialStreamResult,
|
|
113
|
+
FinancialStreamSimulator,
|
|
114
|
+
)
|
|
115
|
+
from shape_simulation.hybrid import HybridConfig, HybridResult, HybridSimulator
|
|
116
|
+
from shape_simulation.iot_patterns import (
|
|
117
|
+
IoTTelemetryConfig,
|
|
118
|
+
IoTTelemetryResult,
|
|
119
|
+
IoTTelemetrySimulator,
|
|
120
|
+
)
|
|
121
|
+
from shape_simulation.operational_log_patterns import (
|
|
122
|
+
OperationalLogConfig,
|
|
123
|
+
OperationalLogResult,
|
|
124
|
+
OperationalLogSimulator,
|
|
125
|
+
)
|
|
126
|
+
from shape_simulation.pulse_patterns import (
|
|
127
|
+
PulseDemandConfig,
|
|
128
|
+
PulseDemandSimulator,
|
|
129
|
+
PulseSimResult,
|
|
130
|
+
)
|
|
131
|
+
from shape_simulation.scd2_file_drops import (
|
|
132
|
+
SCD2FileDropConfig,
|
|
133
|
+
SCD2FileDropResult,
|
|
134
|
+
SCD2FileDropSimulator,
|
|
135
|
+
)
|
|
136
|
+
from shape_simulation.state_machine import (
|
|
137
|
+
StateDefinition,
|
|
138
|
+
TransitionRule,
|
|
139
|
+
WorkflowConfig,
|
|
140
|
+
WorkflowResult,
|
|
141
|
+
WorkflowSimulator,
|
|
142
|
+
get_preset_workflow,
|
|
143
|
+
)
|
|
144
|
+
from shape_simulation.stream_emit import StreamEmitConfig, StreamEmitResult, StreamEmitter
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def __getattr__(name: str) -> Any:
|
|
148
|
+
module = _EXPORTS.get(name)
|
|
149
|
+
if module is None:
|
|
150
|
+
raise AttributeError(f"module 'shape_simulation' has no attribute {name!r}")
|
|
151
|
+
value = getattr(importlib.import_module(module), name)
|
|
152
|
+
globals()[name] = value
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def __dir__() -> list[str]:
|
|
157
|
+
return sorted([*globals(), *_EXPORTS])
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Shared helpers of the pattern simulators (clickstream, financial, IoT, operational log, pulse).
|
|
2
|
+
|
|
3
|
+
The simulators work on Arrow tables (inputs and outputs) with numpy, so the plugin needs nothing
|
|
4
|
+
beyond Shape's own dependencies. Everything random comes from one ``numpy`` generator seeded by
|
|
5
|
+
the configuration, ids included: the same configuration and seed give the same tables.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, ClassVar
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pyarrow as pa # type: ignore[import-untyped]
|
|
18
|
+
import pyarrow.compute as pc # type: ignore[import-untyped]
|
|
19
|
+
|
|
20
|
+
FORMATS = ("parquet", "csv", "jsonl")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---- ids ------------------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def uuid_strings(rng: np.random.Generator, n: int) -> list[str]:
|
|
27
|
+
"""``n`` version-4 style UUID strings drawn from ``rng`` (so a seed reproduces them)."""
|
|
28
|
+
if n <= 0:
|
|
29
|
+
return []
|
|
30
|
+
raw = rng.integers(0, 256, size=(n, 16), dtype=np.uint8)
|
|
31
|
+
raw[:, 6] = (raw[:, 6] & 0x0F) | 0x40
|
|
32
|
+
raw[:, 8] = (raw[:, 8] & 0x3F) | 0x80
|
|
33
|
+
hexa = raw.tobytes().hex()
|
|
34
|
+
return [
|
|
35
|
+
f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
|
|
36
|
+
for h in (hexa[i * 32 : (i + 1) * 32] for i in range(n))
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def hex_strings(rng: np.random.Generator, n: int, length: int) -> list[str]:
|
|
41
|
+
"""``n`` random lowercase hex strings of ``length`` characters."""
|
|
42
|
+
if n <= 0:
|
|
43
|
+
return []
|
|
44
|
+
nbytes = (length + 1) // 2
|
|
45
|
+
raw = rng.integers(0, 256, size=(n, nbytes), dtype=np.uint8).tobytes().hex()
|
|
46
|
+
width = nbytes * 2
|
|
47
|
+
return [raw[i * width : i * width + length] for i in range(n)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---- weighted draws -------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pick(
|
|
54
|
+
rng: np.random.Generator,
|
|
55
|
+
values: list[Any] | np.ndarray,
|
|
56
|
+
size: int,
|
|
57
|
+
p: list[float] | np.ndarray | None = None,
|
|
58
|
+
) -> np.ndarray:
|
|
59
|
+
"""``size`` draws from ``values`` (uniform, or with probabilities ``p``, normalised)."""
|
|
60
|
+
if p is not None:
|
|
61
|
+
weights = np.asarray(p, dtype=np.float64)
|
|
62
|
+
weights = weights / weights.sum()
|
|
63
|
+
return np.asarray(values)[rng.choice(len(values), size=size, p=weights)]
|
|
64
|
+
return np.asarray(values)[rng.integers(0, len(values), size=size)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- Arrow in -------------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def as_table(obj: Any) -> pa.Table:
|
|
71
|
+
"""An Arrow table from a table, a record batch, a mapping of columns or a pandas frame."""
|
|
72
|
+
if isinstance(obj, pa.Table):
|
|
73
|
+
return obj
|
|
74
|
+
if isinstance(obj, pa.RecordBatch):
|
|
75
|
+
return pa.Table.from_batches([obj])
|
|
76
|
+
if isinstance(obj, Mapping):
|
|
77
|
+
return pa.table(dict(obj))
|
|
78
|
+
to_arrow = getattr(obj, "to_arrow", None)
|
|
79
|
+
if callable(to_arrow):
|
|
80
|
+
out = to_arrow()
|
|
81
|
+
if isinstance(out, pa.Table):
|
|
82
|
+
return out
|
|
83
|
+
if hasattr(obj, "__arrow_c_stream__"):
|
|
84
|
+
return pa.table(obj)
|
|
85
|
+
if hasattr(obj, "columns") and hasattr(obj, "dtypes"): # a pandas frame
|
|
86
|
+
return pa.Table.from_pandas(obj, preserve_index=False)
|
|
87
|
+
raise TypeError(f"cannot read {type(obj).__name__} as an Arrow table")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def table_mapping(tables: Any) -> dict[str, pa.Table]:
|
|
91
|
+
"""``tables`` (a mapping of tables, or a generation result with ``.tables``) as a dict."""
|
|
92
|
+
inner = getattr(tables, "tables", tables)
|
|
93
|
+
if not isinstance(inner, Mapping):
|
|
94
|
+
raise TypeError("tables must map table names to tables")
|
|
95
|
+
return {str(k): as_table(v) for k, v in inner.items()}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_number(value: Any) -> bool:
|
|
99
|
+
return not isinstance(value, bool) and isinstance(value, int | float | np.integer | np.floating)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_settings(
|
|
103
|
+
config: Any,
|
|
104
|
+
*,
|
|
105
|
+
positive: tuple[str, ...] = (),
|
|
106
|
+
non_negative: tuple[str, ...] = (),
|
|
107
|
+
probabilities: tuple[str, ...] = (),
|
|
108
|
+
non_empty: tuple[str, ...] = (),
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Refuse settings a simulator cannot use, with a ``ValueError`` that names the setting.
|
|
111
|
+
|
|
112
|
+
Every field whose default is a number must be a number (``--set users=abc`` gives text);
|
|
113
|
+
``positive`` ones are above 0 (they divide), ``non_negative`` ones 0 or more,
|
|
114
|
+
``probabilities`` in [0, 1] and ``non_empty`` lists hold at least one item. ``None`` is
|
|
115
|
+
accepted where it is the default (an unset optional)."""
|
|
116
|
+
import dataclasses
|
|
117
|
+
|
|
118
|
+
for f in dataclasses.fields(config):
|
|
119
|
+
default = f.default
|
|
120
|
+
if isinstance(default, bool) or not isinstance(default, int | float):
|
|
121
|
+
continue
|
|
122
|
+
value = getattr(config, f.name)
|
|
123
|
+
if not _is_number(value):
|
|
124
|
+
raise ValueError(f"{f.name} must be a number, got {value!r}")
|
|
125
|
+
for names, strict, high, what in (
|
|
126
|
+
(positive, True, None, "above 0"),
|
|
127
|
+
(non_negative, False, None, "0 or more"),
|
|
128
|
+
(probabilities, False, 1.0, "between 0 and 1"),
|
|
129
|
+
):
|
|
130
|
+
for name in names:
|
|
131
|
+
value = getattr(config, name)
|
|
132
|
+
if value is None:
|
|
133
|
+
continue
|
|
134
|
+
if not _is_number(value):
|
|
135
|
+
raise ValueError(f"{name} must be a number, got {value!r}")
|
|
136
|
+
number = float(value)
|
|
137
|
+
# written so that NaN fails every rule
|
|
138
|
+
inside = number > 0 if strict else number >= 0
|
|
139
|
+
if high is not None:
|
|
140
|
+
inside = inside and number <= high
|
|
141
|
+
if not inside:
|
|
142
|
+
raise ValueError(f"{name} must be {what}, got {value!r}")
|
|
143
|
+
for name in non_empty:
|
|
144
|
+
if not getattr(config, name):
|
|
145
|
+
raise ValueError(f"{name} must hold at least one item")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def timestamp_us(col: pa.ChunkedArray | pa.Array) -> tuple[np.ndarray, np.ndarray, str | None]:
|
|
149
|
+
"""A date, timestamp or ISO-8601 string column as ``(microseconds, valid, timezone)``.
|
|
150
|
+
|
|
151
|
+
``microseconds`` is int64 since the epoch (0 where null), ``valid`` marks the non-null
|
|
152
|
+
cells and ``timezone`` is the column's time zone, if it had one.
|
|
153
|
+
"""
|
|
154
|
+
if isinstance(col, pa.ChunkedArray):
|
|
155
|
+
col = col.combine_chunks() if col.num_chunks else pa.array([], type=col.type)
|
|
156
|
+
tz: str | None = None
|
|
157
|
+
if pa.types.is_timestamp(col.type):
|
|
158
|
+
tz = col.type.tz
|
|
159
|
+
col = col.cast(pa.timestamp("us", tz))
|
|
160
|
+
elif pa.types.is_date(col.type):
|
|
161
|
+
col = col.cast(pa.timestamp("us"))
|
|
162
|
+
elif pa.types.is_string(col.type) or pa.types.is_large_string(col.type):
|
|
163
|
+
col = _text_times(col)
|
|
164
|
+
else:
|
|
165
|
+
raise TypeError(f"{col.type} is not a date or timestamp type")
|
|
166
|
+
valid = ~np.asarray(col.is_null().to_numpy(zero_copy_only=False), dtype=bool)
|
|
167
|
+
ints = col.cast(pa.int64()).fill_null(0).to_numpy(zero_copy_only=False)
|
|
168
|
+
return ints.astype(np.int64), valid, tz
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _text_times(col: pa.Array) -> pa.Array:
|
|
172
|
+
"""ISO-8601 text as naive ``timestamp[us]``: a time with ``Z`` or an offset becomes its UTC
|
|
173
|
+
wall time, one without is taken as it is. Text that is not a time raises ``ValueError``."""
|
|
174
|
+
try:
|
|
175
|
+
return pc.cast(col, pa.timestamp("us"))
|
|
176
|
+
except pa.ArrowInvalid:
|
|
177
|
+
pass
|
|
178
|
+
try: # every time carries a zone
|
|
179
|
+
return pc.cast(col, pa.timestamp("us", "UTC")).cast(pa.timestamp("us"))
|
|
180
|
+
except pa.ArrowInvalid:
|
|
181
|
+
pass
|
|
182
|
+
values: list[datetime | None] = []
|
|
183
|
+
for text in col.to_pylist(): # some with a zone, some without
|
|
184
|
+
if text is None:
|
|
185
|
+
values.append(None)
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
value = datetime.fromisoformat(text)
|
|
189
|
+
except ValueError:
|
|
190
|
+
raise ValueError(f"{text!r} is not an ISO-8601 date or time") from None
|
|
191
|
+
if value.tzinfo is not None:
|
|
192
|
+
value = value.astimezone(UTC).replace(tzinfo=None)
|
|
193
|
+
values.append(value)
|
|
194
|
+
return pa.array(values, pa.timestamp("us"))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def timestamps(us: np.ndarray, tz: str | None = None, valid: np.ndarray | None = None) -> pa.Array:
|
|
198
|
+
"""A ``timestamp[us]`` array (``tz`` if given) from microseconds, null where not ``valid``."""
|
|
199
|
+
mask = None if valid is None else ~np.asarray(valid, dtype=bool)
|
|
200
|
+
return pa.array(
|
|
201
|
+
np.asarray(us, dtype=np.int64).astype("datetime64[us]"),
|
|
202
|
+
type=pa.timestamp("us", tz),
|
|
203
|
+
mask=mask,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_start(text: str) -> int:
|
|
208
|
+
"""An ISO-8601 instant as microseconds since the epoch (a missing zone means UTC)."""
|
|
209
|
+
moment = datetime.fromisoformat(text)
|
|
210
|
+
if moment.tzinfo is None:
|
|
211
|
+
moment = moment.replace(tzinfo=UTC)
|
|
212
|
+
return int(moment.timestamp() * 1_000_000)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def float_values(col: pa.ChunkedArray | pa.Array) -> tuple[np.ndarray, np.ndarray]:
|
|
216
|
+
"""A numeric column as ``(float64 values, valid)``; null cells are NaN."""
|
|
217
|
+
arr = pc.cast(col, pa.float64())
|
|
218
|
+
if isinstance(arr, pa.ChunkedArray):
|
|
219
|
+
arr = arr.combine_chunks() if arr.num_chunks else pa.array([], type=pa.float64())
|
|
220
|
+
valid = ~np.asarray(arr.is_null().to_numpy(zero_copy_only=False), dtype=bool)
|
|
221
|
+
values = arr.fill_null(float("nan")).to_numpy(zero_copy_only=False).astype(np.float64)
|
|
222
|
+
return values, valid
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def float_array(values: np.ndarray) -> pa.Array:
|
|
226
|
+
"""A float64 Arrow array from ``values``, null where the value is NaN."""
|
|
227
|
+
values = np.asarray(values, dtype=np.float64)
|
|
228
|
+
return pa.array(values, type=pa.float64(), mask=np.isnan(values))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def empty_table(schema: pa.Schema) -> pa.Table:
|
|
232
|
+
return schema.empty_table()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def combine(tables: list[pa.Table]) -> pa.Table:
|
|
236
|
+
"""The rows of ``tables`` stacked, with the union of their columns (missing cells are null).
|
|
237
|
+
|
|
238
|
+
Where the same column has different types (decimal and float amounts, say) numeric
|
|
239
|
+
columns become float64 and anything else text.
|
|
240
|
+
"""
|
|
241
|
+
names: list[str] = []
|
|
242
|
+
types: dict[str, pa.DataType] = {}
|
|
243
|
+
for table in tables:
|
|
244
|
+
for field in table.schema:
|
|
245
|
+
if field.name not in types:
|
|
246
|
+
names.append(field.name)
|
|
247
|
+
types[field.name] = field.type
|
|
248
|
+
elif types[field.name] != field.type:
|
|
249
|
+
a, b = types[field.name], field.type
|
|
250
|
+
numeric = all(
|
|
251
|
+
pa.types.is_integer(t) or pa.types.is_floating(t) or pa.types.is_decimal(t)
|
|
252
|
+
for t in (a, b)
|
|
253
|
+
)
|
|
254
|
+
types[field.name] = pa.float64() if numeric else pa.string()
|
|
255
|
+
schema = pa.schema([pa.field(n, types[n]) for n in names])
|
|
256
|
+
parts = []
|
|
257
|
+
for table in tables:
|
|
258
|
+
cols = []
|
|
259
|
+
for name in names:
|
|
260
|
+
if name in table.column_names:
|
|
261
|
+
cols.append(pc.cast(table.column(name), types[name]))
|
|
262
|
+
else:
|
|
263
|
+
cols.append(pa.nulls(table.num_rows, type=types[name]))
|
|
264
|
+
parts.append(pa.Table.from_arrays(cols, schema=schema))
|
|
265
|
+
return pa.concat_tables(parts)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def value_counts(values: np.ndarray) -> dict[str, int]:
|
|
269
|
+
"""``{value: count}`` ordered by count (largest first; ties by first appearance)."""
|
|
270
|
+
if len(values) == 0:
|
|
271
|
+
return {}
|
|
272
|
+
uniq, first, counts = np.unique(values, return_index=True, return_counts=True)
|
|
273
|
+
order = np.lexsort((first, -counts))
|
|
274
|
+
return {str(uniq[i]): int(counts[i]) for i in order}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---- results --------------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class TablesResult:
|
|
281
|
+
"""What every simulator's result offers: its tables, as flat events, and on disk."""
|
|
282
|
+
|
|
283
|
+
TABLES: ClassVar[tuple[str, ...]] = ()
|
|
284
|
+
stats: dict[str, Any]
|
|
285
|
+
|
|
286
|
+
def table_map(self) -> dict[str, pa.Table]:
|
|
287
|
+
"""Every output table by name."""
|
|
288
|
+
return {name: getattr(self, name) for name in self.TABLES}
|
|
289
|
+
|
|
290
|
+
def events(self, table: str, *, seq_start: int = 0) -> pa.RecordBatch:
|
|
291
|
+
"""The rows of ``table`` as flat stream events: its columns plus ``_shape_table``,
|
|
292
|
+
``_shape_seq`` and (when it has a date or timestamp column) ``_shape_event_time``.
|
|
293
|
+
Feed them to ``shape.streaming.emit`` sinks or an emitter."""
|
|
294
|
+
from shape.streaming.emit.formats import with_event_fields
|
|
295
|
+
|
|
296
|
+
source = self.table_map().get(table)
|
|
297
|
+
if source is None:
|
|
298
|
+
raise KeyError(f"no table {table!r}; the result has {', '.join(self.table_map())}")
|
|
299
|
+
batch = source.combine_chunks().to_batches()
|
|
300
|
+
merged = batch[0] if batch else pa.RecordBatch.from_pylist([], schema=source.schema)
|
|
301
|
+
events: pa.RecordBatch = with_event_fields(merged, table, seq_start)
|
|
302
|
+
return events
|
|
303
|
+
|
|
304
|
+
def write(self, directory: str | Path, fmt: str = "parquet") -> dict[str, Path]:
|
|
305
|
+
"""Write every table to ``directory`` as ``<table>.<fmt>`` (parquet, csv or jsonl)
|
|
306
|
+
and return the paths."""
|
|
307
|
+
if fmt not in FORMATS:
|
|
308
|
+
raise ValueError(f"unknown format {fmt!r}; choose from {', '.join(FORMATS)}")
|
|
309
|
+
from shape.builtins.sinks.files import CsvSink, JsonlSink, ParquetSink
|
|
310
|
+
|
|
311
|
+
sink: Any = {"parquet": ParquetSink, "csv": CsvSink, "jsonl": JsonlSink}[fmt]()
|
|
312
|
+
out = Path(directory)
|
|
313
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
written: dict[str, Path] = {}
|
|
315
|
+
for name, table in self.table_map().items():
|
|
316
|
+
sink.write(str(out) + "/", name, table.to_batches(), schema=table.schema)
|
|
317
|
+
written[name] = out / f"{name}.{fmt}"
|
|
318
|
+
(out / "stats.json").write_text(
|
|
319
|
+
json.dumps(self.stats, indent=2, default=str) + "\n", encoding="utf-8", newline="\n"
|
|
320
|
+
)
|
|
321
|
+
return written
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Arrow helpers for the file-drop, SCD2, stream and workflow simulators (P6-04a).
|
|
2
|
+
|
|
3
|
+
The simulators take generated tables as Arrow tables: a ``dict[str, pa.Table]``, a generation
|
|
4
|
+
result (its ``tables``), or pandas frames (converted when a frame is given; pandas stays
|
|
5
|
+
optional). Files are written through the ``shape.sinks`` plugins, so a file drop writes the
|
|
6
|
+
same Parquet, CSV and JSON Lines as ``shape generate``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as dt
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pyarrow as pa # type: ignore[import-untyped]
|
|
18
|
+
import pyarrow.compute as pc # type: ignore[import-untyped]
|
|
19
|
+
|
|
20
|
+
FILE_FORMATS = ("parquet", "csv", "jsonl")
|
|
21
|
+
"""The formats a drop can write (the sink names of the same files)."""
|
|
22
|
+
|
|
23
|
+
DATE_WORDS = ("date", "timestamp", "created", "updated")
|
|
24
|
+
"""A column named with one of these is a candidate time column when no column is a timestamp."""
|
|
25
|
+
|
|
26
|
+
CADENCES: dict[str, dt.timedelta] = {
|
|
27
|
+
"daily": dt.timedelta(days=1),
|
|
28
|
+
"hourly": dt.timedelta(hours=1),
|
|
29
|
+
"every_15m": dt.timedelta(minutes=15),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def as_tables(source: Any) -> dict[str, pa.Table]:
|
|
34
|
+
"""``source`` as ``{name: Arrow table}``: a mapping of tables or frames, or an object with a
|
|
35
|
+
``tables`` mapping (a generation result)."""
|
|
36
|
+
tables = (
|
|
37
|
+
source.tables if hasattr(source, "tables") and not isinstance(source, Mapping) else source
|
|
38
|
+
)
|
|
39
|
+
if not isinstance(tables, Mapping):
|
|
40
|
+
raise TypeError("expected a mapping of table name to table, or a generation result")
|
|
41
|
+
return {str(name): as_table(table) for name, table in tables.items()}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def as_table(table: Any) -> pa.Table:
|
|
45
|
+
"""One Arrow table from an Arrow table, record batch or pandas frame."""
|
|
46
|
+
if isinstance(table, pa.Table):
|
|
47
|
+
return table
|
|
48
|
+
if isinstance(table, pa.RecordBatch):
|
|
49
|
+
return pa.Table.from_batches([table])
|
|
50
|
+
if hasattr(table, "to_arrow") and callable(table.to_arrow): # a generation table wrapper
|
|
51
|
+
return as_table(table.to_arrow())
|
|
52
|
+
if hasattr(table, "iloc"): # a pandas frame
|
|
53
|
+
return pa.Table.from_pandas(table, preserve_index=False)
|
|
54
|
+
raise TypeError(f"cannot read {type(table).__name__} as a table")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_format(fmt: str) -> str:
|
|
58
|
+
if fmt not in FILE_FORMATS:
|
|
59
|
+
raise ValueError(f"Unsupported file format: {fmt!r} (use one of {', '.join(FILE_FORMATS)})")
|
|
60
|
+
return fmt
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_cadence(cadence: str) -> dt.timedelta:
|
|
64
|
+
if cadence not in CADENCES:
|
|
65
|
+
raise ValueError(f"unknown cadence {cadence!r}; use one of {', '.join(CADENCES)}")
|
|
66
|
+
return CADENCES[cadence]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_day(text: str, name: str) -> dt.datetime:
|
|
70
|
+
"""``YYYY-MM-DD`` as a datetime at midnight; the error names the setting."""
|
|
71
|
+
try:
|
|
72
|
+
return dt.datetime.strptime(text, "%Y-%m-%d")
|
|
73
|
+
except (TypeError, ValueError):
|
|
74
|
+
raise ValueError(f"{name} must be a date as YYYY-MM-DD, not {text!r}") from None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_table(table: pa.Table, path: Path, fmt: str) -> None:
|
|
78
|
+
"""Write ``table`` to ``path`` as ``fmt`` (parquet, csv or jsonl) with the ``shape.sinks``
|
|
79
|
+
plugin of that name; the folder is created."""
|
|
80
|
+
from shape.plugins.host import default_host
|
|
81
|
+
|
|
82
|
+
check_format(fmt)
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
sink = default_host().get("shape.sinks", fmt)
|
|
85
|
+
sink.write(str(path), path.stem, iter(table.to_batches()), schema=table.schema)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_temporal(t: pa.DataType) -> bool:
|
|
89
|
+
return bool(pa.types.is_timestamp(t) or pa.types.is_date(t))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def to_timestamps(column: pa.ChunkedArray | pa.Array) -> pa.Array:
|
|
93
|
+
"""``column`` as naive microsecond timestamps (UTC wall time for a zoned type); a value that
|
|
94
|
+
is not a date becomes null. Dates, timestamps and ISO-8601 strings are understood."""
|
|
95
|
+
col = column.combine_chunks() if isinstance(column, pa.ChunkedArray) else column
|
|
96
|
+
t = col.type
|
|
97
|
+
if pa.types.is_dictionary(t):
|
|
98
|
+
col = col.cast(t.value_type)
|
|
99
|
+
t = col.type
|
|
100
|
+
if pa.types.is_timestamp(t):
|
|
101
|
+
return col.cast(pa.timestamp("us"), safe=False)
|
|
102
|
+
if pa.types.is_date(t):
|
|
103
|
+
return col.cast(pa.timestamp("ms")).cast(pa.timestamp("us"))
|
|
104
|
+
if pa.types.is_string(t) or pa.types.is_large_string(t):
|
|
105
|
+
try:
|
|
106
|
+
return col.cast(pa.timestamp("us"))
|
|
107
|
+
except pa.ArrowInvalid:
|
|
108
|
+
values: list[dt.datetime | None] = []
|
|
109
|
+
for v in col.to_pylist():
|
|
110
|
+
try:
|
|
111
|
+
values.append(None if v is None else dt.datetime.fromisoformat(v))
|
|
112
|
+
except ValueError:
|
|
113
|
+
values.append(None)
|
|
114
|
+
return pa.array(values, pa.timestamp("us"))
|
|
115
|
+
return pa.nulls(len(col), pa.timestamp("us"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def temporal_column(table: pa.Table) -> str | None:
|
|
119
|
+
"""The column that says when a row happened: the first timestamp column; else the first
|
|
120
|
+
column named like a date (``date``, ``timestamp``, ``created``, ``updated``) whose first
|
|
121
|
+
values are dates, ISO-8601 text or date types. A number is not a date."""
|
|
122
|
+
for f in table.schema:
|
|
123
|
+
if pa.types.is_timestamp(f.type):
|
|
124
|
+
return str(f.name)
|
|
125
|
+
for f in table.schema:
|
|
126
|
+
name = str(f.name).lower()
|
|
127
|
+
if not any(w in name for w in DATE_WORDS):
|
|
128
|
+
continue
|
|
129
|
+
t = f.type
|
|
130
|
+
if pa.types.is_date(t):
|
|
131
|
+
return str(f.name)
|
|
132
|
+
if pa.types.is_string(t) or pa.types.is_large_string(t):
|
|
133
|
+
head = table.column(f.name).drop_null().slice(0, 5)
|
|
134
|
+
if len(head) and to_timestamps(head).null_count == 0:
|
|
135
|
+
return str(f.name)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def window_mask(stamps: pa.Array, start: dt.datetime, end: dt.datetime) -> pa.Array:
|
|
140
|
+
"""Rows with ``start <= time < end`` (a null time is outside every window)."""
|
|
141
|
+
lo = pa.scalar(start, pa.timestamp("us"))
|
|
142
|
+
hi = pa.scalar(end, pa.timestamp("us"))
|
|
143
|
+
return pc.fill_null(pc.and_(pc.greater_equal(stamps, lo), pc.less(stamps, hi)), False)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def take_mask(table: pa.Table, mask: pa.Array | np.ndarray[Any, Any]) -> pa.Table:
|
|
147
|
+
"""The rows where ``mask`` is true, in table order."""
|
|
148
|
+
return table.filter(mask if isinstance(mask, pa.Array) else pa.array(mask))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def concat(tables: list[pa.Table]) -> pa.Table:
|
|
152
|
+
return pa.concat_tables(tables, promote_options="default")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def iso_utc_now() -> str:
|
|
156
|
+
return dt.datetime.now(dt.UTC).isoformat()
|
shape_simulation/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""``shape simulate``: run a simulator on a domain's generated tables (P6-04).
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
shape simulate file-drop retail --from 2024-01-01 --to 2024-01-31 -o landing/
|
|
6
|
+
shape simulate stream retail --table order --sink file -o events.jsonl
|
|
7
|
+
shape simulate workflow --preset order_fulfillment --entities 1000 -o out/
|
|
8
|
+
|
|
9
|
+
Each simulator is a sub-command, registered by a handler module listed in ``HANDLER_MODULES``;
|
|
10
|
+
a handler module has ``register(sub)``, which adds its parsers and gives each one a ``run``
|
|
11
|
+
default (a function of the parsed arguments returning the exit code). The target of a simulator
|
|
12
|
+
that works on tables is a domain name or a generation schema file; the tables are generated at
|
|
13
|
+
``--scale`` and ``--seed`` and handed to the simulator.
|
|
14
|
+
|
|
15
|
+
Nothing heavy loads at import time: the handlers import Arrow, the engine and the simulators
|
|
16
|
+
when they run.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import importlib
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
SHAPE_API = "1.0"
|
|
25
|
+
|
|
26
|
+
HANDLER_MODULES = [
|
|
27
|
+
"shape_simulation.cli_batch",
|
|
28
|
+
"shape_simulation.cli_patterns",
|
|
29
|
+
]
|
|
30
|
+
"""The modules that add simulators to ``shape simulate``, one line each."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SimulateCommand:
|
|
34
|
+
"""``shape simulate KIND ...``."""
|
|
35
|
+
|
|
36
|
+
name = "simulate"
|
|
37
|
+
help = (
|
|
38
|
+
"run a simulator: file drop, SCD2 drop, stream, hybrid, workflow, clickstream, financial, "
|
|
39
|
+
"iot, operational-log, pulse"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def configure(self, parser: Any) -> None:
|
|
43
|
+
sub = parser.add_subparsers(dest="simulate_cmd", required=True, metavar="KIND")
|
|
44
|
+
for module in HANDLER_MODULES:
|
|
45
|
+
importlib.import_module(module).register(sub)
|
|
46
|
+
|
|
47
|
+
def run(self, args: Any) -> int:
|
|
48
|
+
return int(args.run(args))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def generate_tables(target: str, scale: str | None, seed: int | None) -> dict[str, Any]:
|
|
52
|
+
"""The tables of a domain (or schema file) at ``scale`` and ``seed``, as Arrow tables."""
|
|
53
|
+
from shape.cli.generation import load_target
|
|
54
|
+
from shape.generation.engine import Engine
|
|
55
|
+
|
|
56
|
+
schema = load_target(target)
|
|
57
|
+
return dict(Engine(schema, scale=scale, seed=seed).generate().tables)
|