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.
- rulebricks_spark/__init__.py +37 -0
- rulebricks_spark/_config.py +33 -0
- rulebricks_spark/_partition.py +418 -0
- rulebricks_spark/_schema.py +249 -0
- rulebricks_spark/_version.py +3 -0
- rulebricks_spark/databricks.py +211 -0
- rulebricks_spark/exceptions.py +26 -0
- rulebricks_spark/solve.py +241 -0
- rulebricks_spark-0.1.0.dist-info/METADATA +273 -0
- rulebricks_spark-0.1.0.dist-info/RECORD +12 -0
- rulebricks_spark-0.1.0.dist-info/WHEEL +4 -0
- rulebricks_spark-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|