rulebricks-spark 0.1.0__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,37 @@
1
+ """Apache Spark integration for Rulebricks.
2
+
3
+ Apply Rulebricks decision rules to Spark DataFrames at scale, with automatic
4
+ bulk batching, intra-partition concurrency, response-schema inference, and
5
+ per-row error isolation.
6
+
7
+ Example
8
+ -------
9
+ >>> from rulebricks_spark import solve
10
+ >>> scored = solve(
11
+ ... spark.table("breach_reports"),
12
+ ... rule="breach-triage",
13
+ ... api_key=dbutils.secrets.get("rulebricks", "api_key"),
14
+ ... )
15
+ >>> scored.display()
16
+
17
+ See ``help(solve)`` for all options, including ``input_mapping``,
18
+ ``output_schema``, ``on_conflict``, ``batch_size``, and concurrency knobs.
19
+ """
20
+
21
+ from rulebricks_spark._version import __version__
22
+ from rulebricks_spark.exceptions import (
23
+ RuleExecutionError,
24
+ RulebricksSparkError,
25
+ SchemaConflictError,
26
+ SchemaInferenceError,
27
+ )
28
+ from rulebricks_spark.solve import solve
29
+
30
+ __all__ = [
31
+ "solve",
32
+ "RulebricksSparkError",
33
+ "SchemaConflictError",
34
+ "SchemaInferenceError",
35
+ "RuleExecutionError",
36
+ "__version__",
37
+ ]
@@ -0,0 +1,33 @@
1
+ """Internal configuration container passed to partition workers.
2
+
3
+ Must contain only primitives so it serializes cleanly across Spark workers.
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Dict, List, Tuple
8
+
9
+
10
+ @dataclass
11
+ class SolveConfig:
12
+ """Config bundle sent to each Spark executor.
13
+
14
+ All fields must be picklable primitives. No SDK client objects.
15
+ """
16
+
17
+ rule_slug: str
18
+ api_key: str
19
+ base_url: str
20
+ batch_size: int
21
+ max_concurrent: int
22
+ input_mapping: Dict[str, str]
23
+ on_missing_input: str
24
+ on_error: str
25
+ timeout_seconds: float
26
+ max_retries: int
27
+ # List of (field_name, rulebricks_type) tuples for the rule's response schema.
28
+ # Rulebricks types: "number" | "string" | "boolean" | "date" | "list" | "object".
29
+ output_columns: List[Tuple[str, str]] = field(default_factory=list)
30
+ input_columns: List[str] = field(default_factory=list)
31
+ # Test hook: if set, used instead of importing rulebricks.Rulebricks on
32
+ # executors. Must be a picklable class (module-level, not a closure).
33
+ _client_class: object = None
@@ -0,0 +1,418 @@
1
+ """Per-partition worker executed on Spark executors.
2
+
3
+ Each Spark partition invokes :func:`make_partition_worker`'s returned closure
4
+ with an iterator of pandas ``DataFrame`` chunks. The worker:
5
+
6
+ 1. Converts each chunk's rows into ``bulk_solve`` request payloads, honoring
7
+ ``input_mapping``.
8
+ 2. Splits the chunk into batches of ``batch_size`` and issues them
9
+ concurrently via a thread pool (``max_concurrent_requests_per_partition``).
10
+ 3. Retries retryable failures with exponential backoff.
11
+ 4. Coerces rule output values into the declared Spark types.
12
+ 5. Emits a pandas ``DataFrame`` matching the declared output schema, including
13
+ a ``_rb_error`` column that is null on success and carries the error string
14
+ on failure when ``on_error='continue'``.
15
+
16
+ Imports of ``rulebricks``, ``pandas``, and ``numpy`` happen inside the worker
17
+ closure so the module is importable on the driver even if workers are
18
+ configured differently.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import math
25
+ import time
26
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
27
+
28
+ from ._config import SolveConfig
29
+ from .exceptions import RuleExecutionError
30
+
31
+
32
+ def make_partition_worker(config: SolveConfig) -> Callable:
33
+ """Build a ``mapInPandas`` worker function closed over ``config``.
34
+
35
+ The returned callable takes ``Iterator[pd.DataFrame]`` and yields
36
+ ``pd.DataFrame`` chunks whose columns match the output schema.
37
+ """
38
+
39
+ def worker(pdf_iter):
40
+ import pandas as pd # noqa: F401 (imported for its side effect of availability check)
41
+
42
+ client = _make_executor_client(config)
43
+
44
+ for pdf in pdf_iter:
45
+ if len(pdf) == 0:
46
+ yield _empty_output(pdf, config)
47
+ continue
48
+ yield _process_partition(pdf, client, config)
49
+
50
+ return worker
51
+
52
+
53
+ # --- partition processing ----------------------------------------------------
54
+
55
+
56
+ def _process_partition(pdf, client, config: SolveConfig):
57
+ """Run bulk_solve over one pandas DataFrame chunk with concurrency + retries."""
58
+ from concurrent.futures import ThreadPoolExecutor
59
+
60
+ n = len(pdf)
61
+ payloads = _build_payloads(pdf, config)
62
+
63
+ batches: List[Tuple[int, List[Dict[str, Any]]]] = [
64
+ (i, payloads[i : i + config.batch_size]) for i in range(0, n, config.batch_size)
65
+ ]
66
+
67
+ def run_batch(batch_tuple: Tuple[int, List[Dict[str, Any]]]):
68
+ start_idx, batch_payloads = batch_tuple
69
+ try:
70
+ response = _bulk_solve_with_retry(client, config, batch_payloads)
71
+ return start_idx, _normalize_response(response), None
72
+ except Exception as e:
73
+ return start_idx, None, _format_error(e)
74
+
75
+ if config.max_concurrent > 1 and len(batches) > 1:
76
+ with ThreadPoolExecutor(max_workers=config.max_concurrent) as pool:
77
+ batch_results = list(pool.map(run_batch, batches))
78
+ else:
79
+ batch_results = [run_batch(b) for b in batches]
80
+
81
+ # Initialize per-row output columns.
82
+ output_data: Dict[str, List[Any]] = {
83
+ name: [None] * n for name, _ in config.output_columns
84
+ }
85
+ errors: List[Optional[str]] = [None] * n
86
+
87
+ for start_idx, response_list, err in batch_results:
88
+ batch_len = min(config.batch_size, n - start_idx)
89
+
90
+ if err is not None:
91
+ if config.on_error == "raise":
92
+ raise RuleExecutionError(
93
+ f"bulk_solve failed on partition batch at row {start_idx}: {err}"
94
+ )
95
+ for j in range(batch_len):
96
+ errors[start_idx + j] = err
97
+ continue
98
+
99
+ for j in range(batch_len):
100
+ if j < len(response_list) and isinstance(response_list[j], dict):
101
+ row_result = response_list[j]
102
+ # Surface rule-reported row errors (some deployments return
103
+ # {"error": "..."} items mixed into the batch response).
104
+ row_error = row_result.get("error") or row_result.get("_error")
105
+ if row_error and config.on_error != "raise":
106
+ errors[start_idx + j] = str(row_error)
107
+ for name, rb_type in config.output_columns:
108
+ output_data[name][start_idx + j] = _coerce_output(
109
+ row_result.get(name), rb_type
110
+ )
111
+ else:
112
+ errors[start_idx + j] = "missing_response_for_row"
113
+
114
+ return _assemble_output(pdf, output_data, errors, config)
115
+
116
+
117
+ # --- payload construction ----------------------------------------------------
118
+
119
+
120
+ def _build_payloads(pdf, config: SolveConfig) -> List[Dict[str, Any]]:
121
+ """Convert a pandas DataFrame into a list of bulk_solve request dicts.
122
+
123
+ ``input_mapping`` is applied as ``{dataframe_col: rule_field}`` — DataFrame
124
+ columns without a mapping are passed through using their existing name.
125
+ """
126
+ mapping = config.input_mapping or {}
127
+
128
+ # Vectorized rename; pandas is happy with the empty-mapping case.
129
+ renamed = pdf.rename(columns=mapping) if mapping else pdf
130
+ records = renamed.to_dict(orient="records")
131
+
132
+ return [{k: _coerce_input(v) for k, v in rec.items()} for rec in records]
133
+
134
+
135
+ def _coerce_input(val: Any) -> Any:
136
+ """Normalize a pandas/numpy scalar into a JSON-serializable Python value."""
137
+ if val is None:
138
+ return None
139
+
140
+ if isinstance(val, float):
141
+ if math.isnan(val):
142
+ return None
143
+ return val
144
+
145
+ # pandas-specific null types
146
+ try:
147
+ import pandas as pd
148
+
149
+ if val is pd.NaT:
150
+ return None
151
+ if isinstance(val, pd.Timestamp):
152
+ return val.isoformat()
153
+ except ImportError:
154
+ pass
155
+
156
+ # numpy scalars → native Python
157
+ try:
158
+ import numpy as np
159
+
160
+ if isinstance(val, np.generic):
161
+ return val.item()
162
+ if isinstance(val, np.ndarray):
163
+ return val.tolist()
164
+ except ImportError:
165
+ pass
166
+
167
+ import datetime
168
+
169
+ if isinstance(val, (datetime.datetime, datetime.date)):
170
+ return val.isoformat()
171
+
172
+ return val
173
+
174
+
175
+ # --- output coercion ---------------------------------------------------------
176
+
177
+
178
+ def _coerce_output(val: Any, rb_type: str) -> Any:
179
+ """Convert a rule response value into a Spark-compatible Python value.
180
+
181
+ The Spark output schema is derived from ``rb_type``; this function
182
+ guarantees the value will round-trip cleanly through Arrow.
183
+ """
184
+ if val is None:
185
+ return None
186
+
187
+ if rb_type == "number":
188
+ if isinstance(val, bool):
189
+ return float(val)
190
+ try:
191
+ return float(val)
192
+ except (TypeError, ValueError):
193
+ return None
194
+
195
+ if rb_type == "boolean":
196
+ if isinstance(val, bool):
197
+ return val
198
+ if isinstance(val, (int, float)):
199
+ return bool(val)
200
+ if isinstance(val, str):
201
+ s = val.strip().lower()
202
+ if s in ("true", "yes", "1"):
203
+ return True
204
+ if s in ("false", "no", "0"):
205
+ return False
206
+ return None
207
+
208
+ if rb_type == "date":
209
+ if isinstance(val, str):
210
+ try:
211
+ import pandas as pd
212
+
213
+ return pd.Timestamp(val).to_pydatetime()
214
+ except Exception:
215
+ return None
216
+ import datetime
217
+
218
+ if isinstance(val, (datetime.datetime, datetime.date)):
219
+ return val
220
+ return None
221
+
222
+ if rb_type == "list_typed":
223
+ # User provided explicit ArrayType — pass through, Spark/Arrow will
224
+ # validate element types on write.
225
+ if isinstance(val, list):
226
+ return val
227
+ if isinstance(val, (tuple, set)):
228
+ return list(val)
229
+ return None
230
+
231
+ if rb_type == "struct_typed":
232
+ # User provided explicit StructType — values must already be dicts.
233
+ if isinstance(val, dict):
234
+ return val
235
+ return None
236
+
237
+ # "list", "object", "string" — all StringType outputs.
238
+ if isinstance(val, (dict, list, tuple)):
239
+ try:
240
+ return json.dumps(val, default=_json_default)
241
+ except Exception:
242
+ return str(val)
243
+ if isinstance(val, bool):
244
+ return "true" if val else "false"
245
+ if isinstance(val, str):
246
+ return val
247
+ return str(val)
248
+
249
+
250
+ def _json_default(o: Any) -> Any:
251
+ """Fallback serializer for non-JSON-native objects."""
252
+ import datetime
253
+
254
+ if isinstance(o, (datetime.datetime, datetime.date)):
255
+ return o.isoformat()
256
+ try:
257
+ import numpy as np
258
+
259
+ if isinstance(o, np.generic):
260
+ return o.item()
261
+ except ImportError:
262
+ pass
263
+ return str(o)
264
+
265
+
266
+ # --- response normalization & retries ----------------------------------------
267
+
268
+
269
+ def _normalize_response(response: Any) -> List[Any]:
270
+ """Coerce any bulk_solve return shape into a plain list of dicts."""
271
+ if response is None:
272
+ return []
273
+ if isinstance(response, list):
274
+ return [_to_plain_dict(item) for item in response]
275
+
276
+ # Some SDK versions wrap results in an object with .data / .results.
277
+ for attr in ("data", "results", "items"):
278
+ nested = getattr(response, attr, None)
279
+ if isinstance(nested, list):
280
+ return [_to_plain_dict(item) for item in nested]
281
+
282
+ # Single-item fallback: wrap in a list.
283
+ d = _to_plain_dict(response)
284
+ return [d] if d else []
285
+
286
+
287
+ def _to_plain_dict(item: Any) -> Dict[str, Any]:
288
+ if isinstance(item, dict):
289
+ return item
290
+ for dump_method in ("model_dump", "dict"):
291
+ fn = getattr(item, dump_method, None)
292
+ if callable(fn):
293
+ try:
294
+ result = fn()
295
+ if isinstance(result, dict):
296
+ return result
297
+ except Exception:
298
+ continue
299
+ if hasattr(item, "__dict__"):
300
+ return {k: v for k, v in item.__dict__.items() if not k.startswith("_")}
301
+ return {}
302
+
303
+
304
+ def _bulk_solve_with_retry(client, config: SolveConfig, batch: List[Dict[str, Any]]) -> Any:
305
+ """Invoke bulk_solve with exponential backoff on retryable errors."""
306
+ last_exc: Optional[Exception] = None
307
+ for attempt in range(config.max_retries + 1):
308
+ try:
309
+ return client.rules.bulk_solve(slug=config.rule_slug, request=batch)
310
+ except Exception as e:
311
+ last_exc = e
312
+ if attempt < config.max_retries and _is_retryable(e):
313
+ backoff = min(2**attempt, 30)
314
+ time.sleep(backoff)
315
+ continue
316
+ break
317
+
318
+ assert last_exc is not None
319
+ raise last_exc
320
+
321
+
322
+ def _is_retryable(exc: Exception) -> bool:
323
+ """Best-effort classification of errors worth retrying."""
324
+ # Check HTTP status code if available
325
+ status = getattr(exc, "status_code", None) or getattr(exc, "status", None)
326
+ if isinstance(status, int):
327
+ return status in (408, 425, 429, 500, 502, 503, 504)
328
+
329
+ msg = str(exc).lower()
330
+ markers = (
331
+ "timeout",
332
+ "timed out",
333
+ "connection",
334
+ "reset",
335
+ "temporarily unavailable",
336
+ "429",
337
+ "500",
338
+ "502",
339
+ "503",
340
+ "504",
341
+ "rate limit",
342
+ )
343
+ return any(m in msg for m in markers)
344
+
345
+
346
+ def _format_error(exc: Exception) -> str:
347
+ """Short human-readable error string suitable for the _rb_error column."""
348
+ return f"{type(exc).__name__}: {exc}"
349
+
350
+
351
+ # --- output assembly ---------------------------------------------------------
352
+
353
+
354
+ def _assemble_output(pdf, output_data: Dict[str, List[Any]], errors: List[Optional[str]], config: SolveConfig):
355
+ """Build a pandas DataFrame whose columns match the declared output schema."""
356
+ output_names = {name for name, _ in config.output_columns}
357
+
358
+ # Drop input columns that collide with rule outputs (will be replaced).
359
+ preserved_cols = [c for c in pdf.columns if c not in output_names and c != "_rb_error"]
360
+ result = pdf[preserved_cols].copy() if preserved_cols else pdf.iloc[:, :0].copy()
361
+
362
+ # Append rule output columns in declaration order.
363
+ for name, _ in config.output_columns:
364
+ result[name] = output_data[name]
365
+
366
+ result["_rb_error"] = errors
367
+
368
+ # Match the Spark schema column order exactly:
369
+ # preserved input cols (original order), rule outputs, _rb_error.
370
+ ordered = preserved_cols + [name for name, _ in config.output_columns] + ["_rb_error"]
371
+ return result[ordered]
372
+
373
+
374
+ def _empty_output(pdf, config: SolveConfig):
375
+ """Return a zero-row DataFrame with the correct columns for an empty partition."""
376
+ output_names = {name for name, _ in config.output_columns}
377
+ preserved_cols = [c for c in pdf.columns if c not in output_names and c != "_rb_error"]
378
+
379
+ result = pdf[preserved_cols].copy()
380
+ for name, _ in config.output_columns:
381
+ result[name] = []
382
+ result["_rb_error"] = []
383
+
384
+ ordered = preserved_cols + [name for name, _ in config.output_columns] + ["_rb_error"]
385
+ return result[ordered]
386
+
387
+
388
+ # --- executor-side client ----------------------------------------------------
389
+
390
+
391
+ def _make_executor_client(config: SolveConfig):
392
+ """Instantiate a fresh Rulebricks client on the executor.
393
+
394
+ We don't try to serialize a client from the driver because SDK clients may
395
+ hold non-picklable resources (sessions, event loops) and the cost of
396
+ constructing one per partition is negligible.
397
+ """
398
+ # Test hook: use an injected class instead of the real SDK.
399
+ if config._client_class is not None:
400
+ return config._client_class(api_key=config.api_key)
401
+
402
+ try:
403
+ from rulebricks import Rulebricks
404
+ except ImportError as e:
405
+ raise ImportError(
406
+ "The `rulebricks` package must be installed on Spark executors. "
407
+ "In Databricks, install it as a cluster library or via "
408
+ "`%pip install rulebricks-spark` in the notebook."
409
+ ) from e
410
+
411
+ if config.base_url and config.base_url != "https://rulebricks.com":
412
+ try:
413
+ return Rulebricks(api_key=config.api_key, base_url=config.base_url)
414
+ except TypeError:
415
+ import os
416
+
417
+ os.environ.setdefault("RULEBRICKS_API_URL", config.base_url)
418
+ return Rulebricks(api_key=config.api_key)