databuck-spark-sdk 0.5.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.
databuck/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ import os
2
+
3
+ from .sdk import DataBuck
4
+
5
+ if os.environ.get("DATABUCK_SPARK_SDK_AUTO_DOWNLOAD", "1").lower() not in ("0", "false", "no"):
6
+ os.environ["DATABUCK_SPARK_SDK_JAR"] = DataBuck.download_jar()
7
+
8
+ __all__ = ["DataBuck"]
databuck/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Download the DataBuck Spark SDK JAR after installing the Python package."""
2
+
3
+ from .sdk import DataBuck
4
+
5
+
6
+ if __name__ == "__main__":
7
+ print(DataBuck.download_jar())
@@ -0,0 +1,346 @@
1
+ """Lightweight, DataFrame-first context-aware rule generation for notebooks."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from typing import Any
7
+
8
+
9
+ DEFAULT_MODEL = "gemini-2.5-flash"
10
+ DATAFRAME_PLACEHOLDER = "{{DATAFRAME}}"
11
+
12
+
13
+ def discover_rules(df: Any, context: dict) -> list[dict[str, str]]:
14
+ """Generate portable SQL data-quality rules for the supplied DataFrame.
15
+
16
+ This function uses the caller-owned DataFrame only. It does not create or
17
+ stop a Spark session, connect to DataBuck databases, read source systems,
18
+ or persist rules.
19
+ """
20
+ api_key = (
21
+ context.get("gemini_api_key")
22
+ or os.environ.get("GEMINI_API_KEY")
23
+ or ""
24
+ ).strip()
25
+ if not api_key:
26
+ raise ValueError(
27
+ "A Gemini API key is required. Set context['gemini_api_key'], "
28
+ "or the GEMINI_API_KEY environment variable."
29
+ )
30
+
31
+ rule_count = int(context.get("rule_count", 10))
32
+ if rule_count < 1 or rule_count > 50:
33
+ raise ValueError("context['rule_count'] must be between 1 and 50")
34
+
35
+ pdf_paths = _get_pdf_paths(context)
36
+ prompt = _build_prompt(df, context, rule_count, pdf_paths)
37
+ response_text, audit_response_text = _generate_and_audit_with_gemini(
38
+ api_key=api_key,
39
+ model=context.get("gemini_model", DEFAULT_MODEL),
40
+ prompt=prompt,
41
+ pdf_paths=pdf_paths,
42
+ )
43
+ rules = _parse_rules(response_text)
44
+
45
+ if not rules:
46
+ raise ValueError("Gemini did not return any valid SQL rules")
47
+
48
+ _apply_audit_results(rules, audit_response_text)
49
+ returned_rules = rules if context.get("include_failed_rules", False) else [
50
+ rule for rule in rules if rule["audit_status"] == "PASSED"
51
+ ]
52
+
53
+ if not returned_rules:
54
+ raise ValueError("Gemini audit rejected every generated rule")
55
+
56
+ for index, rule in enumerate(returned_rules, start=1):
57
+ print("\nRule {}: {}".format(index, rule["name"]))
58
+ print("Description: {}".format(rule["description"]))
59
+ print("Reference context: {}".format(rule["reference_context"]))
60
+ print("Audit status: {}".format(rule["audit_status"]))
61
+ print("Audit reason: {}".format(rule["audit_reason"]))
62
+ print(rule["sql"])
63
+
64
+ return returned_rules
65
+
66
+
67
+ def _build_prompt(
68
+ df: Any, context: dict, rule_count: int, pdf_paths: list[str]
69
+ ) -> str:
70
+ schema = [
71
+ {
72
+ "name": field.name,
73
+ "type": field.dataType.simpleString(),
74
+ "nullable": field.nullable,
75
+ }
76
+ for field in df.schema.fields
77
+ ]
78
+
79
+ safe_context = {
80
+ key: value
81
+ for key, value in context.items()
82
+ if key not in {
83
+ "gemini_api_key",
84
+ "gemini_model",
85
+ "rule_count",
86
+ "pdf_paths",
87
+ "include_failed_rules",
88
+ }
89
+ }
90
+ document_names = [os.path.basename(path) for path in pdf_paths]
91
+
92
+ return """You are a data-quality rule generator.
93
+ Generate up to {rule_count} accurate data-quality SQL rules for the DataFrame
94
+ described below. It is acceptable to return fewer rules when the supplied
95
+ reference context does not define enough unambiguous controls.
96
+ The attached PDF documents are approved reference context. Use them when they
97
+ support a rule. If a rule is based on a PDF, identify the relevant policy text
98
+ in reference_context.
99
+
100
+ Return only a JSON array. Every item must contain exactly these string fields:
101
+ name, description, reference_context, sql.
102
+
103
+ Reference requirements:
104
+ - Every rule must be supported by exactly one explicit policy or business
105
+ statement from the supplied context or attached PDFs.
106
+ - reference_context must quote that exact supporting statement. Do not
107
+ paraphrase, combine statements, or claim a reference that does not exist.
108
+ - Do not infer additional business meaning, ID patterns, allowed values,
109
+ thresholds, column relationships, or statuses that are not explicitly stated.
110
+ - Do not downgrade a "valid" requirement into a null/empty check. A validity
111
+ rule requires an explicit format, allowed-value list, or validation condition.
112
+ - A null/empty check is permitted only when the reference explicitly says
113
+ required, present, non-null, non-empty, or mandatory.
114
+ - If a reference is ambiguous or does not define an executable check, omit it.
115
+
116
+ SQL requirements:
117
+ - Use generic SQL only. Do not use BigQuery, Databricks, Spark, Snowflake, or
118
+ database-vendor-specific syntax.
119
+ - Every SQL value must have exactly this shape:
120
+ SELECT * FROM {placeholder} WHERE <invalid-row condition>
121
+ - The WHERE condition must use only columns from the supplied DataFrame.
122
+ - Do not use joins, grouping, ordering, subqueries, or additional statements.
123
+ - A rule must return invalid rows, so its WHERE clause must describe the
124
+ condition that fails the rule.
125
+ - Use only columns in the supplied schema.
126
+ - Do not invent business values, tables, joins, columns, formats, thresholds,
127
+ allowed values, or rule logic.
128
+ - Do not generate INSERT, UPDATE, DELETE, MERGE, DROP, CREATE, ALTER, or CALL.
129
+
130
+ Before returning each rule, internally verify that the SQL checks exactly what
131
+ the quoted reference_context states. Omit the rule if they do not match.
132
+
133
+ DataFrame schema:
134
+ {schema}
135
+
136
+ Business context:
137
+ {context}
138
+
139
+ Attached PDF documents:
140
+ {documents}
141
+ """.format(
142
+ rule_count=rule_count,
143
+ placeholder=DATAFRAME_PLACEHOLDER,
144
+ schema=json.dumps(schema, indent=2),
145
+ context=json.dumps(safe_context, default=str, indent=2),
146
+ documents=json.dumps(document_names),
147
+ )
148
+
149
+
150
+ def _generate_and_audit_with_gemini(
151
+ api_key: str, model: str, prompt: str, pdf_paths: list[str]
152
+ ) -> tuple[str, str]:
153
+ try:
154
+ from google import genai
155
+ from google.genai import types
156
+ except ImportError as exc:
157
+ raise ImportError(
158
+ "Gemini rule discovery requires google-genai. Install it with "
159
+ "`pip install google-genai`."
160
+ ) from exc
161
+
162
+ client = genai.Client(api_key=api_key)
163
+ uploaded_files = []
164
+ try:
165
+ for pdf_path in pdf_paths:
166
+ uploaded_files.append(client.files.upload(file=pdf_path))
167
+
168
+ response = client.models.generate_content(
169
+ model=model,
170
+ contents=[prompt, *uploaded_files],
171
+ config=types.GenerateContentConfig(
172
+ temperature=0.1,
173
+ response_mime_type="application/json",
174
+ ),
175
+ )
176
+ candidate_response_text = (response.text or "").strip()
177
+ candidate_rules = _parse_rules(candidate_response_text)
178
+ if not candidate_rules:
179
+ return candidate_response_text, "[]"
180
+
181
+ audit_response = client.models.generate_content(
182
+ model=model,
183
+ contents=[_build_audit_prompt(prompt, candidate_rules), *uploaded_files],
184
+ config=types.GenerateContentConfig(
185
+ temperature=0.0,
186
+ response_mime_type="application/json",
187
+ ),
188
+ )
189
+ return candidate_response_text, (audit_response.text or "").strip()
190
+ finally:
191
+ # The PDFs are needed only for this single rule-generation request.
192
+ for uploaded_file in uploaded_files:
193
+ try:
194
+ client.files.delete(name=uploaded_file.name)
195
+ except Exception:
196
+ pass
197
+
198
+
199
+ def _build_audit_prompt(reference_prompt: str, candidate_rules: list[dict[str, str]]) -> str:
200
+ return """You are an independent data-quality rule auditor. Do not create,
201
+ rewrite, repair, or improve rules. Audit every candidate rule against the
202
+ reference material below.
203
+
204
+ Return only a JSON array. Every item must contain exactly these fields:
205
+ rule_index (integer, starting at 1), audit_status (PASSED or FAILED), and
206
+ audit_reason (string).
207
+
208
+ Pass a rule only when all conditions are true:
209
+ - Its reference_context exactly quotes one explicit reference statement.
210
+ - Its SQL checks exactly the control stated in that reference.
211
+ - The SQL uses only the supplied DataFrame columns.
212
+ - The SQL has exactly this form: SELECT * FROM {placeholder} WHERE
213
+ <invalid-row condition>. It contains no joins, grouping, ordering,
214
+ subqueries, or additional statements.
215
+ - The SQL does not invent formats, values, thresholds, relationships, or logic.
216
+ - A "valid" reference has an explicit format, allowed-value list, or validation
217
+ condition; null/empty SQL alone must fail such a rule.
218
+ - A null/empty SQL check is supported only by a reference that explicitly says
219
+ required, present, non-null, non-empty, or mandatory.
220
+
221
+ Fail ambiguous, partly supported, over-broad, or mismatched rules. Explain the
222
+ specific mismatch in audit_reason.
223
+
224
+ Reference material:
225
+ {reference_prompt}
226
+
227
+ Candidate rules:
228
+ {candidate_rules}
229
+ """.format(
230
+ placeholder=DATAFRAME_PLACEHOLDER,
231
+ reference_prompt=reference_prompt,
232
+ candidate_rules=json.dumps(candidate_rules, indent=2),
233
+ )
234
+
235
+
236
+ def _apply_audit_results(rules: list[dict[str, str]], audit_response_text: str) -> None:
237
+ audits_by_index = _parse_audit_results(audit_response_text, len(rules))
238
+ for index, rule in enumerate(rules, start=1):
239
+ audit = audits_by_index.get(index)
240
+ if audit is None:
241
+ rule["audit_status"] = "FAILED"
242
+ rule["audit_reason"] = "Auditor did not return a valid audit result."
243
+ else:
244
+ rule["audit_status"] = audit["audit_status"]
245
+ rule["audit_reason"] = audit["audit_reason"]
246
+
247
+
248
+ def _parse_audit_results(audit_response_text: str, rule_count: int) -> dict[int, dict[str, str]]:
249
+ try:
250
+ raw_audits = json.loads(_strip_markdown_fence(audit_response_text))
251
+ except json.JSONDecodeError:
252
+ return {}
253
+
254
+ if not isinstance(raw_audits, list):
255
+ return {}
256
+
257
+ audits_by_index = {}
258
+ for raw_audit in raw_audits:
259
+ if not isinstance(raw_audit, dict):
260
+ continue
261
+ rule_index = raw_audit.get("rule_index")
262
+ audit_status = str(raw_audit.get("audit_status", "")).strip().upper()
263
+ audit_reason = str(raw_audit.get("audit_reason", "")).strip()
264
+ if (
265
+ not isinstance(rule_index, int)
266
+ or rule_index < 1
267
+ or rule_index > rule_count
268
+ or audit_status not in {"PASSED", "FAILED"}
269
+ or not audit_reason
270
+ ):
271
+ continue
272
+ audits_by_index[rule_index] = {
273
+ "audit_status": audit_status,
274
+ "audit_reason": audit_reason,
275
+ }
276
+ return audits_by_index
277
+
278
+
279
+ def _get_pdf_paths(context: dict) -> list[str]:
280
+ pdf_paths = context.get("pdf_paths", [])
281
+ if pdf_paths is None:
282
+ return []
283
+ if not isinstance(pdf_paths, (list, tuple)) or not all(
284
+ isinstance(path, str) and path.strip() for path in pdf_paths
285
+ ):
286
+ raise TypeError("context['pdf_paths'] must be a list of non-empty file paths")
287
+
288
+ resolved_paths = []
289
+ for path in pdf_paths:
290
+ if not path.lower().endswith(".pdf"):
291
+ raise ValueError("Only PDF files are supported in context['pdf_paths']")
292
+ if not os.path.isfile(path):
293
+ raise FileNotFoundError(
294
+ "PDF was not found on the notebook driver: {}".format(path)
295
+ )
296
+ resolved_paths.append(path)
297
+ return resolved_paths
298
+
299
+
300
+ def _parse_rules(response_text: str) -> list[dict[str, str]]:
301
+ try:
302
+ raw_rules = json.loads(_strip_markdown_fence(response_text))
303
+ except json.JSONDecodeError as exc:
304
+ raise ValueError("Gemini returned invalid JSON for generated rules") from exc
305
+
306
+ if not isinstance(raw_rules, list):
307
+ raise ValueError("Gemini must return a JSON array of rules")
308
+
309
+ rules = []
310
+ for raw_rule in raw_rules:
311
+ if not isinstance(raw_rule, dict):
312
+ continue
313
+
314
+ name = str(raw_rule.get("name", "")).strip()
315
+ description = str(raw_rule.get("description", "")).strip()
316
+ reference_context = str(raw_rule.get("reference_context", "")).strip()
317
+ sql = str(raw_rule.get("sql", "")).strip()
318
+ if (
319
+ not name
320
+ or not description
321
+ or not reference_context
322
+ or not re.match(r"^SELECT\b", sql, re.IGNORECASE)
323
+ ):
324
+ continue
325
+ if DATAFRAME_PLACEHOLDER not in sql:
326
+ continue
327
+ if re.search(r"\b(INSERT|UPDATE|DELETE|MERGE|DROP|CREATE|ALTER|CALL)\b", sql, re.IGNORECASE):
328
+ continue
329
+
330
+ rules.append(
331
+ {
332
+ "name": name,
333
+ "description": description,
334
+ "reference_context": reference_context,
335
+ "sql": sql,
336
+ }
337
+ )
338
+
339
+ return rules
340
+
341
+
342
+ def _strip_markdown_fence(value: str) -> str:
343
+ value = value.strip()
344
+ if value.startswith("```") and value.endswith("```"):
345
+ value = value.split("\n", 1)[1].rsplit("```", 1)[0]
346
+ return value.strip()