spark-data-quality 1.0.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,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: spark-data-quality
3
+ Version: 1.0.0
4
+ Summary: SparkDQAgent — Data Quality validation package for K8s Spark pods
5
+ Author-email: khailas <khailas.rangath@saal.ai>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: requests>=2.28.0
13
+ Requires-Dist: great-expectations==0.18.12
14
+ Requires-Dist: trino>=0.320.0
15
+ Requires-Dist: PyYAML>=6.0
16
+ Provides-Extra: spark
17
+ Requires-Dist: pyspark>=3.1.1; extra == "spark"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest; extra == "dev"
20
+ Requires-Dist: ruff; extra == "dev"
21
+ Dynamic: license-file
22
+
23
+ # spark-dq
24
+
25
+ Data Quality validation library for K8s Spark pods. Runs Great Expectations + Trino hybrid checks and persists results to the DQ Engine API.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install . # core (GE + Trino)
31
+ pip install ".[spark]" # includes PySpark
32
+ pip install -e ".[dev]" # editable + dev tools
33
+ ```
34
+
35
+ ## Package structure
36
+
37
+ ```
38
+ src/spark_dq/
39
+ ├── quality.py # SparkDQAgent — main orchestrator
40
+ ├── utils.py # JSON serialisation helpers
41
+ ├── helpers/
42
+ │ ├── api_client.py # DQ Engine HTTP client (GET /config, POST /save-results)
43
+ │ ├── expectations.py # Expectation splitting (fast/slow) and result builders
44
+ │ ├── trino_executor.py # Trino fast-path — batched SQL for 5 assertion types
45
+ │ ├── ge_executor.py # GE slow-path — parallel in-memory validators
46
+ │ ├── sql_utils.py # SQL injection prevention utilities
47
+ │ └── exceptions.py # DQEngineAPIError
48
+ └── data_quality_helper/
49
+ ├── const.py # GE datasource config templates
50
+ ├── datacontext.py # In-memory GE DataContext builder
51
+ ├── batch_request.py # RuntimeBatchRequest factory
52
+ ├── expectation_suite.py # Suite builder from assertion dicts
53
+ ├── ge_utils.py # Mapping ID resolution
54
+ └── checkpoint_conf.py # Checkpoint runner (legacy)
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ### Single table
60
+
61
+ ```python
62
+ from spark_dq.quality import SparkDQAgent
63
+
64
+ agent = SparkDQAgent(
65
+ catalog="my_catalog",
66
+ schema="public",
67
+ table="sales_data",
68
+ data_quality_url="http://dq-engine:8000/api/v1/spark",
69
+ catalog_type="unmanaged",
70
+ table_id=13,
71
+ test_suite_id=37,
72
+ trino_host="trino-host:443",
73
+ trino_user="user",
74
+ trino_pwd="pwd",
75
+ )
76
+
77
+ cfg = agent.get_config() # single GET /config call
78
+ results = agent.validate(df) # hybrid Trino + GE execution
79
+ ```
80
+
81
+ ### Suite run (auto-discover tables)
82
+
83
+ ```python
84
+ agent = SparkDQAgent(
85
+ catalog="", schema="", table="",
86
+ data_quality_url="http://dq-engine:8000/api/v1/spark",
87
+ catalog_type="unmanaged",
88
+ test_suite_id=37,
89
+ )
90
+ table_configs = agent.get_all_table_configs() # returns list of per-table configs
91
+ ```
92
+
93
+ ## Demo
94
+
95
+ ```bash
96
+ # Single table
97
+ python demo.py \
98
+ --catalog khtestpostgresss --schema public --table sales_data_new \
99
+ --dq_url http://localhost:8000/api/v1/spark \
100
+ --trino_host trino-dev.digixt.ae:443 --trino_user datalake --trino_pwd <pwd> \
101
+ --table_id 13 --test_suite_id 37
102
+
103
+ # Suite run (auto-discovers tables)
104
+ python demo.py \
105
+ --test_suite_id 37 \
106
+ --dq_url http://localhost:8000/api/v1/spark \
107
+ --trino_host trino-dev.digixt.ae:443 --trino_user datalake --trino_pwd <pwd>
108
+ ```
109
+
110
+ ## Hybrid execution
111
+
112
+ When Trino credentials are provided, the agent splits expectations into two paths:
113
+
114
+ | Path | Assertion types | How |
115
+ |------|----------------|-----|
116
+ | **Trino fast-path** | `not_be_null`, `be_unique`, `be_between`, `match_regex`, `row_count_to_be_between` | Single batched SQL query |
117
+ | **GE slow-path** | All others | Parallel in-memory GE validators |
118
+
119
+ Both paths run **concurrently**. Results are merged and saved via `POST /save-results`.
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,23 @@
1
+ spark_data_quality-1.0.0.dist-info/licenses/LICENSE,sha256=ESYyLizI0WWtxMeS7rGVcX3ivMezm-HOd5WdeOh-9oU,1056
2
+ spark_dq/__init__.py,sha256=udiZe4rI0Ttfpxo4KwiE-sGjTxEtO20Pa3YxzsErLVY,98
3
+ spark_dq/quality.py,sha256=EQhg8krc6gVznGYHCa_UPcIPt8efET7ja1JwsZ5gFwY,10251
4
+ spark_dq/utils.py,sha256=s_Swkufse9Tu08f5b0EzwKog0NbWFLbV33BamFuUiYQ,1097
5
+ spark_dq/data_quality_helper/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ spark_dq/data_quality_helper/batch_request.py,sha256=TLnlxx7CqfH0THCCjS9O07JEgHIqWpax_dLjASOom4Y,840
7
+ spark_dq/data_quality_helper/checkpoint_conf.py,sha256=RcTHntduI92nngS7VC7ZvTjMEvoqbeKSHK0HC2IsY8c,1213
8
+ spark_dq/data_quality_helper/const.py,sha256=HzofT1dA8xZetqswnHzS6nOKh-cCJCa9iuRtQfiFPsM,749
9
+ spark_dq/data_quality_helper/datacontext.py,sha256=iUwXomXjE8dh5JrZseZG6wuzDlsn7xnQpFa_z136KAA,1497
10
+ spark_dq/data_quality_helper/expectation_suite.py,sha256=B9Jla-Jh3R-YLrLnygQxGv4e8OThzLiCljkzcEuTb_s,688
11
+ spark_dq/data_quality_helper/ge_utils.py,sha256=w0_JMg-Gj-3StjbEasAoglIFKgU16y3tNcAn1BdmxGI,943
12
+ spark_dq/data_quality_helper/utils.py,sha256=9LSquN7Xt0x40WNkB2dBCBub1o6Kj4M8CEfoi45pVEg,1326
13
+ spark_dq/helpers/__init__.py,sha256=YFotyjTGDiIMFuhcwKThHdjnZ9DzA9E9uSWkirb3Ve0,660
14
+ spark_dq/helpers/api_client.py,sha256=Shh_WeNFVd_YpeyIvRx_NvcCyuvTyEcZayFLrNdidqw,3725
15
+ spark_dq/helpers/exceptions.py,sha256=hfhp0tuV9oFYIIukm1b81Sx67LS3imlTs8HaM8NWK9c,277
16
+ spark_dq/helpers/expectations.py,sha256=_-L-Mfo6NwWCpI_I1HEIn2qAvwnLx5jor3REECZUdfQ,1414
17
+ spark_dq/helpers/ge_executor.py,sha256=dudNWzRDyvHR9plBa7QXu5dlF9trFQzhjN6xuG8pDpg,5276
18
+ spark_dq/helpers/sql_utils.py,sha256=vP62kDPYNCULsETIBoaPV_huhiWoD9k8LQc7fXN30kI,833
19
+ spark_dq/helpers/trino_executor.py,sha256=BiBcwE9ISM7ykeamgt7kmo04ldBWUT55NDp5B6gvO_I,5697
20
+ spark_data_quality-1.0.0.dist-info/METADATA,sha256=ewM868gPLUHWbCHsepprSx9L8rHwmdyzm6CaD6tQ81g,3937
21
+ spark_data_quality-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
22
+ spark_data_quality-1.0.0.dist-info/top_level.txt,sha256=Js6y25e8v--TS1TsPtdTCw02kmmPV0VlgBluNy0q-D0,9
23
+ spark_data_quality-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ spark_dq
spark_dq/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SparkDQAgent — Data Quality validation package for K8s Spark pods."""
2
+
3
+ __version__ = "1.0.0"
File without changes
@@ -0,0 +1,23 @@
1
+ import logging
2
+ from great_expectations.core.batch import RuntimeBatchRequest
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ def create_batchrequest(df, datasource_name: str, data_asset_name: str):
8
+ """
9
+ Wrap a pre-loaded Spark DataFrame in a GE RuntimeBatchRequest.
10
+
11
+ The DataFrame is already loaded by ``quality_spark.py`` (either via
12
+ ``spark.sql`` for Iceberg or Trino JDBC for external catalogs).
13
+ No data fetching happens here.
14
+ """
15
+ batch = RuntimeBatchRequest(
16
+ datasource_name=datasource_name,
17
+ data_connector_name="default_runtime_data_connector_name",
18
+ data_asset_name=data_asset_name,
19
+ runtime_parameters={"batch_data": df},
20
+ batch_identifiers={"default_identifier_name": "default_identifier"},
21
+ )
22
+ logger.info("Batch request created from Spark DataFrame")
23
+ return batch
@@ -0,0 +1,30 @@
1
+ from datetime import datetime
2
+
3
+ from spark_dq.data_quality_helper.const import ACTION_LIST, DATETIME_FORMAT
4
+
5
+ # BOOLEAN_ONLY: GE only computes pass/fail per expectation — no unexpected
6
+ # value collection, no data movement back to the driver. This eliminates the
7
+ # hidden per-expectation Spark action that makes validation slow at scale.
8
+ RESULT_FORMAT = {
9
+ "result_format": "BOOLEAN_ONLY",
10
+ "include_unexpected_rows": False,
11
+ "partial_unexpected_count": 0,
12
+ }
13
+
14
+
15
+ def run_checkpoint(context, data_asset_name, expectation_suite_name, batch_request):
16
+ checkpoint = context.add_or_update_checkpoint(
17
+ name=f"{data_asset_name}.{expectation_suite_name}.chk",
18
+ module_name="great_expectations.checkpoint",
19
+ class_name="Checkpoint",
20
+ run_name_template=f'{datetime.now().strftime(DATETIME_FORMAT)}-my-run-name-template',
21
+ )
22
+
23
+ checkpoint_run_result = checkpoint.run(
24
+ run_name=f'{datetime.now().strftime(DATETIME_FORMAT)}-my-run-name-template',
25
+ batch_request=batch_request,
26
+ expectation_suite_name=expectation_suite_name,
27
+ action_list=ACTION_LIST,
28
+ runtime_configuration={"result_format": RESULT_FORMAT},
29
+ )
30
+ return checkpoint_run_result
@@ -0,0 +1,27 @@
1
+ DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
2
+
3
+ DATASOURCE_CONFIG = """
4
+ name: {datasource_name}
5
+ class_name: Datasource
6
+ module_name: great_expectations.datasource
7
+ execution_engine:
8
+ class_name: SparkDFExecutionEngine
9
+ module_name: great_expectations.execution_engine
10
+
11
+ data_connectors:
12
+ default_runtime_data_connector_name:
13
+ class_name: RuntimeDataConnector
14
+ batch_identifiers:
15
+ - default_identifier_name
16
+ """
17
+
18
+ ACTION_LIST = [
19
+ {
20
+ "name": "store_validation_result",
21
+ "action": {"class_name": "StoreValidationResultAction"},
22
+ },
23
+ {
24
+ "name": "store_evaluation_params",
25
+ "action": {"class_name": "StoreEvaluationParametersAction"},
26
+ },
27
+ ]
@@ -0,0 +1,39 @@
1
+ from great_expectations.data_context.types.base import DataContextConfig
2
+
3
+
4
+ def create_datacontext():
5
+ """Build a GE DataContextConfig backed entirely by in-memory stores."""
6
+ return DataContextConfig(
7
+ **{
8
+ "config_version": 1.0,
9
+ "stores": {
10
+ "expectations_store": {
11
+ "class_name": "ExpectationsStore",
12
+ "store_backend": {
13
+ "class_name": "InMemoryStoreBackend",
14
+ },
15
+ },
16
+ "validations_store": {
17
+ "class_name": "ValidationsStore",
18
+ "store_backend": {
19
+ "class_name": "InMemoryStoreBackend",
20
+ },
21
+ },
22
+ "evaluation_parameter_store": {
23
+ "class_name": "EvaluationParameterStore",
24
+ },
25
+ "checkpoint_store": {
26
+ "class_name": "CheckpointStore",
27
+ "store_backend": {
28
+ "class_name": "InMemoryStoreBackend",
29
+ },
30
+ },
31
+ },
32
+ "expectations_store_name": "expectations_store",
33
+ "validations_store_name": "validations_store",
34
+ "evaluation_parameter_store_name": "evaluation_parameter_store",
35
+ "checkpoint_store_name": "checkpoint_store",
36
+ "data_docs_sites": {},
37
+ "anonymous_usage_statistics": {"enabled": False},
38
+ }
39
+ )
@@ -0,0 +1,17 @@
1
+ from great_expectations.core.expectation_configuration import ExpectationConfiguration
2
+
3
+
4
+ def create_expectation_function(expectation_list, suite):
5
+ # print(f"###inside create_expectation_function, expectation_list:{expectation_list}")
6
+ for expectation in expectation_list:
7
+ expectation = expectation["assertion_info"]
8
+
9
+ expectation = ExpectationConfiguration(
10
+ expectation_type=list(expectation.keys())[0],
11
+ kwargs=list(expectation.values())[0],
12
+ )
13
+ # while adding, GE will overwrite the assertion arguments, if the assertion already exists .
14
+
15
+ suite.add_expectation(expectation_configuration=expectation)
16
+
17
+ return suite
@@ -0,0 +1,20 @@
1
+ def get_mapping_id(actions_result: dict, expectations_list: list):
2
+ """Match a GE validation result back to its assertion_mapping_id."""
3
+ expectation_type_action = actions_result["expectation_config"]["expectation_type"]
4
+ expectation_type_kwargs = dict(actions_result["expectation_config"]["kwargs"])
5
+ expectation_type_kwargs.pop("batch_id", None)
6
+ expectation_type_kwargs.pop("catch_exceptions", None)
7
+
8
+ for expectation in expectations_list:
9
+ expectation_info = expectation["assertion_info"]
10
+ expectation_type_key = list(expectation_info.keys())[0]
11
+ expectations_type_value = dict(list(expectation_info.values())[0])
12
+ expectations_type_value.pop("catch_exceptions", None)
13
+
14
+ if (
15
+ expectation_type_key == expectation_type_action
16
+ and expectations_type_value == expectation_type_kwargs
17
+ ):
18
+ return expectation["assertion_mapping_id"]
19
+
20
+ return None
@@ -0,0 +1,50 @@
1
+ import base64
2
+ import os
3
+ import shutil
4
+ import urllib.parse
5
+
6
+
7
+ def convert_quality_report_to_binary(quality_report_lctn):
8
+ quality_report_lctn = quality_report_lctn.split("file:")[1]
9
+
10
+ with open(rf"{urllib.parse.unquote(quality_report_lctn)}", "rb") as f:
11
+ file_content = f.read()
12
+
13
+ # Encode the file content as base64
14
+ html_binary = base64.b64encode(file_content)
15
+
16
+ return html_binary
17
+
18
+
19
+ def delete_quality_files(table_id: int, suite_id: int, quality_path: str):
20
+ # to delete the folder and its contents
21
+ folder_paths = [
22
+ os.path.join(
23
+ quality_path,
24
+ "expectations",
25
+ f"{table_id}_{suite_id}",
26
+ ),
27
+ os.path.join(
28
+ quality_path,
29
+ "uncommitted",
30
+ "validations",
31
+ f"{table_id}_{suite_id}",
32
+ ),
33
+ os.path.join(
34
+ quality_path,
35
+ "checkpoints",
36
+ f"{table_id}_{suite_id}",
37
+ ),
38
+ os.path.join(
39
+ quality_path,
40
+ "uncommitted",
41
+ "data_docs",
42
+ "local_site",
43
+ f"{table_id}_{suite_id}",
44
+ ),
45
+ ]
46
+
47
+ for folder_path in folder_paths:
48
+ if os.path.exists(folder_path):
49
+ shutil.rmtree(folder_path)
50
+ print(f"Folder '{folder_path}' and its contents have been deleted.")
@@ -0,0 +1,25 @@
1
+ from spark_dq.helpers.exceptions import DQEngineAPIError
2
+ from spark_dq.helpers.expectations import (
3
+ TRINO_FAST_TYPES,
4
+ exp_type_and_kwargs,
5
+ make_raw_result,
6
+ split_expectations,
7
+ )
8
+ from spark_dq.helpers.sql_utils import safe_identifier, safe_numeric, safe_regex
9
+ from spark_dq.helpers import api_client
10
+ from spark_dq.helpers import trino_executor
11
+ from spark_dq.helpers import ge_executor
12
+
13
+ __all__ = [
14
+ "DQEngineAPIError",
15
+ "TRINO_FAST_TYPES",
16
+ "exp_type_and_kwargs",
17
+ "make_raw_result",
18
+ "split_expectations",
19
+ "safe_identifier",
20
+ "safe_numeric",
21
+ "safe_regex",
22
+ "api_client",
23
+ "trino_executor",
24
+ "ge_executor",
25
+ ]
@@ -0,0 +1,121 @@
1
+ import json
2
+ import logging
3
+
4
+ import requests
5
+
6
+ from spark_dq.helpers.exceptions import DQEngineAPIError
7
+ from spark_dq.data_quality_helper.ge_utils import get_mapping_id
8
+ from spark_dq.utils import jsonify
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ API_TIMEOUT = 30
13
+
14
+
15
+ def api_call(base_url: str, method: str, endpoint: str, body=None):
16
+ """Make an HTTP request to the DQ Engine API."""
17
+ url = base_url + endpoint
18
+ headers = {
19
+ "Accept": "application/json, text/plain, */*",
20
+ "Content-Type": "application/json; charset=UTF-8",
21
+ }
22
+ if body is None:
23
+ body = {}
24
+ if method.upper() == "GET":
25
+ resp = requests.request(
26
+ method, url, params=body, headers=headers, timeout=API_TIMEOUT,
27
+ )
28
+ else:
29
+ resp = requests.request(
30
+ method, url, data=body, headers=headers, timeout=API_TIMEOUT,
31
+ )
32
+
33
+ if resp.status_code in (200, 201):
34
+ return resp.json().get("data")
35
+
36
+ logger.error(
37
+ f"DQ Engine API error — {method} {url}\n"
38
+ f" Status : {resp.status_code}\n"
39
+ f" Body : {body}\n"
40
+ f" Response: {resp.content}"
41
+ )
42
+ raise DQEngineAPIError(resp.status_code, resp.text)
43
+
44
+
45
+ def fetch_config(base_url: str, params: dict) -> dict | list:
46
+ """GET /config — works for both single-table and suite-run modes.
47
+
48
+ - Single-table (params has catalog_name/schema_name/table_name):
49
+ returns a single config dict.
50
+ - Suite-run (params has only test_suite_id):
51
+ returns a list of per-table config dicts.
52
+ """
53
+ data = api_call(base_url, "GET", "/config", body=params)
54
+ if isinstance(data, dict) and "tables" in data:
55
+ return data["tables"]
56
+ return data
57
+
58
+
59
+ def save_results(
60
+ base_url: str,
61
+ *,
62
+ table_id: int,
63
+ schema_id: int,
64
+ catalog_id: int,
65
+ suite_id: int,
66
+ execution_type: str,
67
+ raw_results: list,
68
+ expectations_list: list,
69
+ ) -> dict:
70
+ """POST /save-results from a list of normalised raw-result dicts."""
71
+ total = len(raw_results)
72
+ passed = sum(1 for r in raw_results if r.get("success"))
73
+ stats = {
74
+ "evaluated_expectations": total,
75
+ "successful_expectations": passed,
76
+ "unsuccessful_expectations": total - passed,
77
+ "success_percent": round(passed / total * 100, 4) if total else 100.0,
78
+ }
79
+
80
+ results_payload = []
81
+ for ar in raw_results:
82
+ ec = ar.get("expectation_config", {})
83
+ exp_type = ec.get("expectation_type", "unknown")
84
+ exp_kwargs = ec.get("kwargs", {})
85
+ exception_info = ar.get("exception_info", {})
86
+
87
+ if isinstance(exception_info, dict) and exception_info.get("raised_exception"):
88
+ status = "SKIPPED"
89
+ elif ar.get("success"):
90
+ status = "SUCCESS"
91
+ else:
92
+ status = "FAILURE"
93
+
94
+ mapping_id = get_mapping_id(
95
+ actions_result=ar,
96
+ expectations_list=expectations_list,
97
+ )
98
+
99
+ run_event = {
100
+ "expectation_config": {"expectation_type": exp_type, "kwargs": exp_kwargs},
101
+ "result": ar.get("result", {}),
102
+ "success": ar.get("success", False),
103
+ "exception_info": exception_info,
104
+ "statistics": stats,
105
+ }
106
+ results_payload.append({
107
+ "assertion_mapping_id": mapping_id,
108
+ "assertion_name": exp_type,
109
+ "status": status,
110
+ "run_event": jsonify(run_event),
111
+ })
112
+
113
+ body = json.dumps({
114
+ "table_id": table_id,
115
+ "schema_id": schema_id,
116
+ "catalog_id": catalog_id,
117
+ "test_suite_id": suite_id,
118
+ "execution_type": execution_type,
119
+ "results": results_payload,
120
+ })
121
+ return api_call(base_url, "POST", "/save-results", body=body)
@@ -0,0 +1,6 @@
1
+ class DQEngineAPIError(Exception):
2
+ """Raised when the DQ Engine API returns a non-success response."""
3
+
4
+ def __init__(self, status_code: int, detail: str):
5
+ self.status_code = status_code
6
+ super().__init__(f"DQ Engine API error ({status_code}): {detail}")
@@ -0,0 +1,43 @@
1
+ from typing import Tuple
2
+
3
+ TRINO_FAST_TYPES = frozenset({
4
+ "expect_column_values_to_not_be_null",
5
+ "expect_column_values_to_be_unique",
6
+ "expect_column_values_to_be_between",
7
+ "expect_column_values_to_match_regex",
8
+ "expect_table_row_count_to_be_between",
9
+ })
10
+
11
+
12
+ def exp_type_and_kwargs(exp: dict) -> Tuple[str, dict]:
13
+ """Extract (expectation_type, kwargs) from an assertion_info dict."""
14
+ info = exp["assertion_info"]
15
+ exp_type = next(iter(info))
16
+ kwargs = dict(info[exp_type])
17
+ return exp_type, kwargs
18
+
19
+
20
+ def make_raw_result(exp_type: str, kwargs: dict, success: bool) -> dict:
21
+ """Build a normalised raw-result dict for save-results payload."""
22
+ return {
23
+ "expectation_config": {
24
+ "expectation_type": exp_type,
25
+ "kwargs": {k: v for k, v in kwargs.items() if k != "catch_exceptions"},
26
+ },
27
+ "success": success,
28
+ "result": {},
29
+ "exception_info": {
30
+ "raised_exception": False,
31
+ "exception_message": None,
32
+ "exception_traceback": None,
33
+ },
34
+ }
35
+
36
+
37
+ def split_expectations(expectations_list: list) -> Tuple[list, list]:
38
+ """Return (fast_exps, slow_exps) split by TRINO_FAST_TYPES membership."""
39
+ fast, slow = [], []
40
+ for exp in expectations_list:
41
+ etype, _ = exp_type_and_kwargs(exp)
42
+ (fast if etype in TRINO_FAST_TYPES else slow).append(exp)
43
+ return fast, slow
@@ -0,0 +1,145 @@
1
+ import logging
2
+ import yaml
3
+ from concurrent.futures import ThreadPoolExecutor, as_completed
4
+ from typing import List
5
+
6
+ from great_expectations.core import RunIdentifier
7
+ from great_expectations.data_context import BaseDataContext
8
+
9
+ from spark_dq.data_quality_helper import (
10
+ batch_request,
11
+ datacontext,
12
+ expectation_suite,
13
+ )
14
+ from spark_dq.data_quality_helper.const import DATASOURCE_CONFIG
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def run_ge(table_name: str, expectations_list: list, df) -> dict:
20
+ """Build in-memory GE context, add expectations, run validator on *df*."""
21
+ env = "prod"
22
+ suite_name = f"quality_{env}_suite_{table_name}"
23
+ asset_name = f"quality_{env}_data_asset_{table_name}"
24
+ ds_name = f"quality_{env}_datasource_{table_name}"
25
+
26
+ data_context_config = datacontext.create_datacontext()
27
+ context = BaseDataContext(project_config=data_context_config)
28
+
29
+ ds_config_dict = yaml.safe_load(
30
+ DATASOURCE_CONFIG.format(datasource_name=ds_name)
31
+ )
32
+ context.add_datasource(**ds_config_dict)
33
+
34
+ context.add_or_update_expectation_suite(expectation_suite_name=suite_name)
35
+ suite_obj = context.get_expectation_suite(expectation_suite_name=suite_name)
36
+ suite_with_validations = expectation_suite.create_expectation_function(
37
+ expectation_list=expectations_list, suite=suite_obj
38
+ )
39
+ context.save_expectation_suite(
40
+ expectation_suite=suite_with_validations,
41
+ expectation_suite_name=suite_name,
42
+ overwrite_existing=True,
43
+ )
44
+
45
+ batch_req = batch_request.create_batchrequest(
46
+ df=df, datasource_name=ds_name, data_asset_name=asset_name
47
+ )
48
+ validator = context.get_validator(
49
+ batch_request=batch_req, expectation_suite=suite_with_validations
50
+ )
51
+ validator.save_expectation_suite(discard_failed_expectations=False)
52
+
53
+ validation_result = validator.validate(
54
+ result_format={
55
+ "result_format": "BOOLEAN_ONLY",
56
+ "include_unexpected_rows": False,
57
+ "partial_unexpected_count": 0,
58
+ },
59
+ )
60
+
61
+ run_id = RunIdentifier(run_name=f"validator-run-{table_name}")
62
+ return {
63
+ "run_results": {
64
+ run_id: {
65
+ "validation_result": (
66
+ validation_result.to_json_dict()
67
+ if hasattr(validation_result, "to_json_dict")
68
+ else dict(validation_result)
69
+ )
70
+ }
71
+ }
72
+ }
73
+
74
+
75
+ def extract_raw_from_checkpoint(checkpoint_run_result: dict) -> list:
76
+ """Unpack a GE validator/checkpoint result into a list of raw dicts."""
77
+ run_results = checkpoint_run_result.get("run_results", {})
78
+ if not run_results:
79
+ return []
80
+ assert_key = next(iter(run_results))
81
+ validation_result = run_results[assert_key]["validation_result"]
82
+
83
+ raw = []
84
+ for ar_obj in validation_result.get("results", []):
85
+ ar = ar_obj if isinstance(ar_obj, dict) else ar_obj.__dict__
86
+ ec_raw = ar.get("expectation_config", {})
87
+
88
+ exp_type, exp_kwargs = "unknown", {}
89
+ if hasattr(ec_raw, "expectation_type"):
90
+ exp_type = ec_raw.expectation_type
91
+ exp_kwargs = dict(ec_raw.kwargs)
92
+ else:
93
+ ec = ec_raw.__dict__ if hasattr(ec_raw, "__dict__") else ec_raw
94
+ if isinstance(ec, dict):
95
+ exp_type = (
96
+ ec.get("expectation_type") or ec.get("_expectation_type", "unknown")
97
+ )
98
+ exp_kwargs = dict(ec.get("kwargs") or ec.get("_kwargs") or {})
99
+
100
+ raw.append({
101
+ "expectation_config": {"expectation_type": exp_type, "kwargs": exp_kwargs},
102
+ "success": ar.get("success", False),
103
+ "result": ar.get("result", {}),
104
+ "exception_info": ar.get("exception_info", {}),
105
+ })
106
+ return raw
107
+
108
+
109
+ def run_ge_chunk(table_name: str, expectations_list: list, df, chunk_idx: int) -> List[dict]:
110
+ """Run a single GE validator for one chunk with unique context names."""
111
+ checkpoint = run_ge(f"{table_name}_p{chunk_idx}", expectations_list, df)
112
+ return extract_raw_from_checkpoint(checkpoint)
113
+
114
+
115
+ def run_ge_parallel(
116
+ table_name: str, expectations_list: list, df, workers: int
117
+ ) -> List[dict]:
118
+ """Split expectations into chunks and run each in its own thread."""
119
+ n = min(workers, len(expectations_list))
120
+ if n <= 1:
121
+ return run_ge_chunk(table_name, expectations_list, df, 0)
122
+
123
+ chunk_size = -(-len(expectations_list) // n)
124
+ chunks = [
125
+ expectations_list[i : i + chunk_size]
126
+ for i in range(0, len(expectations_list), chunk_size)
127
+ ]
128
+ logger.info(
129
+ f"GE parallel: {len(expectations_list)} exps → "
130
+ f"{len(chunks)} chunks of ~{chunk_size}"
131
+ )
132
+
133
+ all_raw: List[dict] = []
134
+ with ThreadPoolExecutor(max_workers=len(chunks)) as pool:
135
+ futures = {
136
+ pool.submit(run_ge_chunk, table_name, chunk, df, idx): idx
137
+ for idx, chunk in enumerate(chunks)
138
+ }
139
+ for future in as_completed(futures):
140
+ idx = futures[future]
141
+ chunk_raw = future.result()
142
+ passed = sum(1 for r in chunk_raw if r["success"])
143
+ logger.info(f" GE chunk {idx} done — {passed}/{len(chunk_raw)} passed")
144
+ all_raw.extend(chunk_raw)
145
+ return all_raw
@@ -0,0 +1,26 @@
1
+ import re
2
+
3
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
4
+
5
+
6
+ def safe_identifier(name: str) -> str:
7
+ """Validate and quote a SQL identifier to prevent injection."""
8
+ if not name or not _IDENTIFIER_RE.match(name):
9
+ raise ValueError(f"Invalid SQL identifier: {name!r}")
10
+ return f'"{name}"'
11
+
12
+
13
+ def safe_numeric(value) -> str:
14
+ """Ensure a value is a valid number literal for SQL."""
15
+ try:
16
+ float(value)
17
+ except (TypeError, ValueError):
18
+ raise ValueError(f"Expected numeric value, got: {value!r}")
19
+ return str(value)
20
+
21
+
22
+ def safe_regex(pattern: str) -> str:
23
+ """Escape single quotes in a regex pattern for SQL string literal."""
24
+ if not isinstance(pattern, str):
25
+ raise ValueError(f"Expected regex string, got: {type(pattern).__name__}")
26
+ return pattern.replace("'", "''")
@@ -0,0 +1,171 @@
1
+ import logging
2
+ from typing import Dict
3
+
4
+ from spark_dq.helpers.expectations import (
5
+ exp_type_and_kwargs,
6
+ make_raw_result,
7
+ )
8
+ from spark_dq.helpers.sql_utils import (
9
+ safe_identifier,
10
+ safe_numeric,
11
+ safe_regex,
12
+ )
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def run_trino_fast_path(
18
+ *,
19
+ trino_host: str,
20
+ trino_user: str,
21
+ trino_pwd: str,
22
+ catalog: str,
23
+ schema: str,
24
+ table: str,
25
+ row_limit: int | None,
26
+ fast_expectations: list,
27
+ ) -> list:
28
+ """
29
+ Execute fast-path expectations as ONE batched SQL query on Trino.
30
+
31
+ Uses validated/quoted identifiers and numeric literals to prevent
32
+ SQL injection. The connection is guaranteed to be closed via finally.
33
+ """
34
+ import trino as trino_mod
35
+
36
+ host_port = trino_host.rsplit(":", 1)
37
+ host = host_port[0]
38
+ port = int(host_port[1]) if len(host_port) > 1 else 443
39
+
40
+ q_catalog = safe_identifier(catalog)
41
+ q_schema = safe_identifier(schema)
42
+ q_table = safe_identifier(table)
43
+ fq_table = f"{q_catalog}.{q_schema}.{q_table}"
44
+
45
+ if row_limit:
46
+ table_ref = f"(SELECT * FROM {fq_table} LIMIT {safe_numeric(row_limit)})"
47
+ else:
48
+ table_ref = fq_table
49
+
50
+ selects = ["COUNT(*) AS __total"]
51
+ nonnull_selects: Dict[str, str] = {}
52
+ index_map: Dict[str, tuple] = {}
53
+ rowcount_exps = []
54
+
55
+ for i, exp in enumerate(fast_expectations):
56
+ etype, kwargs = exp_type_and_kwargs(exp)
57
+ col = kwargs.get("column", "")
58
+ qcol = safe_identifier(col) if col else '""'
59
+
60
+ if etype == "expect_column_values_to_not_be_null":
61
+ alias = f"__n_{i}"
62
+ selects.append(
63
+ f"SUM(CASE WHEN {qcol} IS NULL THEN 1 ELSE 0 END) AS {alias}"
64
+ )
65
+ index_map[alias] = (exp, etype, kwargs, "null")
66
+
67
+ elif etype == "expect_column_values_to_be_unique":
68
+ alias = f"__u_{i}"
69
+ selects.append(
70
+ f"(COUNT({qcol}) - COUNT(DISTINCT {qcol})) AS {alias}"
71
+ )
72
+ index_map[alias] = (exp, etype, kwargs, "unique")
73
+
74
+ elif etype == "expect_column_values_to_be_between":
75
+ alias = f"__b_{i}"
76
+ min_v = kwargs.get("min_value")
77
+ max_v = kwargs.get("max_value")
78
+ parts = [f"{qcol} IS NOT NULL"]
79
+ if min_v is not None and max_v is not None:
80
+ parts.append(
81
+ f"NOT ({qcol} BETWEEN {safe_numeric(min_v)} AND {safe_numeric(max_v)})"
82
+ )
83
+ elif min_v is not None:
84
+ parts.append(f"{qcol} < {safe_numeric(min_v)}")
85
+ elif max_v is not None:
86
+ parts.append(f"{qcol} > {safe_numeric(max_v)}")
87
+ selects.append(
88
+ f"SUM(CASE WHEN {' AND '.join(parts)} THEN 1 ELSE 0 END) AS {alias}"
89
+ )
90
+ index_map[alias] = (exp, etype, kwargs, "between")
91
+
92
+ elif etype == "expect_column_values_to_match_regex":
93
+ alias = f"__r_{i}"
94
+ regex = safe_regex(kwargs.get("regex", ".*"))
95
+ selects.append(
96
+ f"SUM(CASE WHEN {qcol} IS NOT NULL"
97
+ f" AND NOT REGEXP_LIKE(CAST({qcol} AS VARCHAR), '{regex}')"
98
+ f" THEN 1 ELSE 0 END) AS {alias}"
99
+ )
100
+ index_map[alias] = (exp, etype, kwargs, "regex")
101
+
102
+ elif etype == "expect_table_row_count_to_be_between":
103
+ rowcount_exps.append((exp, etype, kwargs))
104
+
105
+ if col and etype != "expect_table_row_count_to_be_between":
106
+ nn_alias = f"__nn_{col}"
107
+ if nn_alias not in nonnull_selects:
108
+ nonnull_selects[nn_alias] = f"COUNT({qcol}) AS {safe_identifier(nn_alias)}"
109
+
110
+ selects.extend(nonnull_selects.values())
111
+
112
+ sql = f"SELECT {', '.join(selects)} FROM {table_ref}"
113
+ logger.info(
114
+ f"Trino fast-path: {len(fast_expectations)} expectations → 1 SQL query | "
115
+ f"SQL: {sql[:500]}"
116
+ )
117
+
118
+ conn = trino_mod.dbapi.connect(
119
+ host=host,
120
+ port=port,
121
+ user=trino_user,
122
+ http_scheme="https",
123
+ auth=trino_mod.auth.BasicAuthentication(trino_user, trino_pwd),
124
+ )
125
+ try:
126
+ cursor = conn.cursor()
127
+ cursor.execute(sql)
128
+ row = cursor.fetchone()
129
+ col_names = [d[0] for d in cursor.description]
130
+ cursor.close()
131
+ finally:
132
+ conn.close()
133
+
134
+ result_map = dict(zip(col_names, row))
135
+ total_rows = int(result_map.get("__total") or 0)
136
+ raw_results = []
137
+
138
+ for alias, (exp, etype, kwargs, check_type) in index_map.items():
139
+ mostly = float(kwargs.get("mostly") or 1.0)
140
+ unexpected = int(result_map.get(alias) or 0)
141
+
142
+ col = kwargs.get("column", "")
143
+ nonnull_count = int(result_map.get(f"__nn_{col}") or total_rows)
144
+ denominator = nonnull_count if check_type != "null" else total_rows
145
+
146
+ if mostly >= 1.0:
147
+ success = unexpected == 0
148
+ else:
149
+ success = (
150
+ (unexpected / denominator) <= (1.0 - mostly)
151
+ if denominator > 0
152
+ else True
153
+ )
154
+ raw_results.append(make_raw_result(etype, kwargs, success))
155
+
156
+ for exp, etype, kwargs in rowcount_exps:
157
+ min_v = kwargs.get("min_value")
158
+ max_v = kwargs.get("max_value")
159
+ success = True
160
+ if min_v is not None and total_rows < min_v:
161
+ success = False
162
+ if max_v is not None and total_rows > max_v:
163
+ success = False
164
+ raw_results.append(make_raw_result(etype, kwargs, success))
165
+
166
+ passed = sum(1 for r in raw_results if r["success"])
167
+ logger.info(
168
+ f"Trino fast-path complete: {passed}/{len(raw_results)} passed "
169
+ f"(total_rows={total_rows:,})"
170
+ )
171
+ return raw_results
spark_dq/quality.py ADDED
@@ -0,0 +1,282 @@
1
+ import logging
2
+ import os
3
+ import time
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from typing import Dict, Any, List
6
+
7
+ from spark_dq.helpers.expectations import split_expectations
8
+ from spark_dq.helpers.api_client import (
9
+ fetch_config,
10
+ save_results,
11
+ )
12
+ from spark_dq.helpers.trino_executor import run_trino_fast_path
13
+ from spark_dq.helpers.ge_executor import run_ge_parallel
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _MAX_SUITE_WORKERS = 8
18
+
19
+
20
+ def _fmt(seconds: float) -> str:
21
+ if seconds < 60:
22
+ return f"{seconds:.2f}s"
23
+ m, s = divmod(seconds, 60)
24
+ return f"{int(m)}m {s:.2f}s"
25
+
26
+
27
+ class SparkDQAgent:
28
+ """
29
+ Data Quality Agent designed for K8s Spark pods.
30
+
31
+ Receives a pre-loaded Spark DataFrame, runs validations, and persists
32
+ results to the Data Quality Engine service via its HTTP API.
33
+
34
+ **Hybrid execution** (active when Trino credentials are supplied):
35
+ - TRINO_FAST_TYPES → single batched SQL query on Trino per suite
36
+ - All other types → Great Expectations on the Spark DataFrame
37
+
38
+ When every expectation in a suite belongs to TRINO_FAST_TYPES the
39
+ caller may pass ``df=None`` — Spark is not touched at all.
40
+
41
+ Remote calls per suite:
42
+ 1. ``GET /config`` — fetch query, table type, suites + expectations
43
+ 2. ``POST /save-results`` — persist results
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ catalog: str,
49
+ schema: str,
50
+ table: str,
51
+ data_quality_url: str,
52
+ catalog_type: str,
53
+ log_level: str = "INFO",
54
+ test_suite_id: int = None,
55
+ table_id: int = None,
56
+ trino_host: str = "",
57
+ trino_user: str = "",
58
+ trino_pwd: str = "",
59
+ is_suite_run: bool = False,
60
+ config: dict = None,
61
+ ):
62
+ self.catalog = catalog
63
+ self.schema = schema
64
+ self.table = table
65
+ self.data_quality_url = data_quality_url.rstrip("/")
66
+ self.catalog_type = catalog_type
67
+ self.log_level = log_level
68
+ self.test_suite_id = test_suite_id
69
+ self.table_id = table_id
70
+ self.trino_host = trino_host or ""
71
+ self.trino_user = trino_user or ""
72
+ self.trino_pwd = trino_pwd or ""
73
+ self.is_suite_run = is_suite_run
74
+ self.row_limit = None
75
+ self._config = config
76
+
77
+ # ------------------------------------------------------------------
78
+ # Public API
79
+ # ------------------------------------------------------------------
80
+
81
+ def get_config(self) -> dict:
82
+ """Fetch config for this table from the API (or return cached).
83
+
84
+ Returns a dict with quality_query, table_type, suites, scan_limit, etc.
85
+ """
86
+ if self._config is None:
87
+ params = {
88
+ "catalog_name": self.catalog,
89
+ "schema_name": self.schema,
90
+ "table_name": self.table,
91
+ "catalog_type": self.catalog_type,
92
+ }
93
+ if self.table_id is not None:
94
+ params["table_id"] = self.table_id
95
+ if self.test_suite_id is not None:
96
+ params["test_suite_id"] = self.test_suite_id
97
+ self._config = fetch_config(self.data_quality_url, params)
98
+ return self._config
99
+
100
+ def get_all_table_configs(self) -> list:
101
+ """Fetch configs for ALL tables in the test suite from the API.
102
+
103
+ Returns a list of config dicts, one per table.
104
+ """
105
+ params = {"test_suite_id": self.test_suite_id, "catalog_type": self.catalog_type}
106
+ return fetch_config(self.data_quality_url, params)
107
+
108
+ def validate(self, df) -> Dict[str, Any]:
109
+ """
110
+ Run validations against the provided Spark DataFrame (or ``None``).
111
+
112
+ All suites are processed **in parallel** via ThreadPoolExecutor.
113
+ Within each suite, Trino fast-path and GE slow-path also run concurrently.
114
+ """
115
+ fqn = f"{self.catalog}.{self.schema}.{self.table}"
116
+ logger.info(f"Starting validation for {fqn}")
117
+
118
+ cfg = self.get_config()
119
+ suites = cfg.get("suites", [])
120
+ schema_id = cfg.get("schema_id")
121
+ catalog_id = cfg.get("catalog_id")
122
+
123
+ config_limit = cfg.get("scan_limit")
124
+ if config_limit is not None:
125
+ self.row_limit = int(config_limit)
126
+
127
+ if not suites:
128
+ logger.warning(f"No suites returned for {fqn}")
129
+ return {}
130
+
131
+ use_hybrid = bool(self.trino_host and self.trino_user)
132
+ all_results: Dict[str, Any] = {}
133
+
134
+ workers = min(len(suites), _MAX_SUITE_WORKERS)
135
+ with ThreadPoolExecutor(max_workers=workers) as pool:
136
+ futures = {
137
+ pool.submit(
138
+ self._validate_suite, suite, df, use_hybrid,
139
+ schema_id=schema_id, catalog_id=catalog_id,
140
+ ): suite
141
+ for suite in suites
142
+ }
143
+ for future in (futures):
144
+ suite = futures[future]
145
+ suite_id = suite["suite_id"]
146
+ table_name = suite.get("table_name", self.table)
147
+ result_key = f"{table_name}_{suite_id}"
148
+ try:
149
+ all_results[result_key] = future.result()
150
+ except Exception as exc:
151
+ logger.error(f"Validation failed for {result_key}: {exc}")
152
+ all_results[result_key] = {"error": str(exc)}
153
+
154
+ logger.info(f"Validation complete for {fqn}")
155
+ return all_results
156
+
157
+ # ------------------------------------------------------------------
158
+ # Internal
159
+ # ------------------------------------------------------------------
160
+
161
+ def _validate_suite(
162
+ self, suite: dict, df, use_hybrid: bool,
163
+ schema_id: int = None, catalog_id: int = None,
164
+ ) -> Dict[str, Any]:
165
+ """Validate a single suite — called in parallel from validate()."""
166
+ suite_id = suite["suite_id"]
167
+ table_id = suite.get("table_id") or self.table_id
168
+ table_name = suite.get("table_name", self.table)
169
+ expectations_list = suite.get("expectations", [])
170
+ ge_workers = min(4, os.cpu_count() or 4)
171
+
172
+ t_suite = time.perf_counter()
173
+
174
+ if use_hybrid:
175
+ all_raw = self._run_hybrid(
176
+ suite_id, table_name, expectations_list, df, ge_workers, t_suite,
177
+ )
178
+ else:
179
+ logger.info(
180
+ f"[suite {suite_id}] GE-only — "
181
+ f"{len(expectations_list)} expectations, workers: {ge_workers}"
182
+ )
183
+ t0 = time.perf_counter()
184
+ all_raw = run_ge_parallel(table_name, expectations_list, df, ge_workers)
185
+ logger.info(
186
+ f"[suite {suite_id}][timer] GE parallel "
187
+ f"({len(expectations_list)} exps, {ge_workers} workers): "
188
+ f"{_fmt(time.perf_counter() - t0)}"
189
+ )
190
+
191
+ t0 = time.perf_counter()
192
+ try:
193
+ save_results(
194
+ self.data_quality_url,
195
+ table_id=table_id,
196
+ schema_id=schema_id,
197
+ catalog_id=catalog_id,
198
+ suite_id=suite_id,
199
+ execution_type="SCHEDULED" if self.is_suite_run else "MANUAL",
200
+ raw_results=all_raw,
201
+ expectations_list=expectations_list,
202
+ )
203
+ except Exception as exc:
204
+ logger.error(
205
+ f"[suite {suite_id}] POST /save-results failed: {exc}. "
206
+ f"Computed {len(all_raw)} results (not persisted)."
207
+ )
208
+ raise
209
+ logger.info(
210
+ f"[suite {suite_id}][timer] POST /save-results "
211
+ f"({len(all_raw)} results): {_fmt(time.perf_counter() - t0)}"
212
+ )
213
+
214
+ passed = sum(1 for r in all_raw if r.get("success"))
215
+ total = len(all_raw)
216
+ logger.info(
217
+ f"[suite {suite_id}][timer] Suite total: {_fmt(time.perf_counter() - t_suite)} "
218
+ f"| passed {passed}/{total}"
219
+ )
220
+ return {
221
+ "evaluated_expectations": total,
222
+ "successful_expectations": passed,
223
+ "unsuccessful_expectations": total - passed,
224
+ "success_percent": round(passed / total * 100, 4) if total else 100.0,
225
+ }
226
+
227
+ def _run_hybrid(
228
+ self,
229
+ suite_id: int,
230
+ table_name: str,
231
+ expectations_list: list,
232
+ df,
233
+ ge_workers: int,
234
+ t_suite: float,
235
+ ) -> List[dict]:
236
+ """Run Trino fast-path and GE slow-path concurrently."""
237
+ fast_exps, slow_exps = split_expectations(expectations_list)
238
+ logger.info(
239
+ f"[suite {suite_id}] Hybrid — "
240
+ f"Trino fast: {len(fast_exps)}, GE slow: {len(slow_exps)}, "
241
+ f"workers: {ge_workers}"
242
+ )
243
+
244
+ fast_raw: List[dict] = []
245
+ ge_raw: List[dict] = []
246
+
247
+ with ThreadPoolExecutor(max_workers=2) as pool:
248
+ futures = {}
249
+ if fast_exps:
250
+ futures[pool.submit(
251
+ run_trino_fast_path,
252
+ trino_host=self.trino_host,
253
+ trino_user=self.trino_user,
254
+ trino_pwd=self.trino_pwd,
255
+ catalog=self.catalog,
256
+ schema=self.schema,
257
+ table=self.table,
258
+ row_limit=self.row_limit,
259
+ fast_expectations=fast_exps,
260
+ )] = "trino"
261
+ if slow_exps:
262
+ futures[pool.submit(
263
+ run_ge_parallel, table_name, slow_exps, df, ge_workers,
264
+ )] = "ge"
265
+
266
+ for future in as_completed(futures):
267
+ label = futures[future]
268
+ if label == "trino":
269
+ fast_raw = future.result()
270
+ logger.info(
271
+ f"[suite {suite_id}][timer] Trino fast-path "
272
+ f"({len(fast_exps)} exps): {_fmt(time.perf_counter() - t_suite)}"
273
+ )
274
+ else:
275
+ ge_raw = future.result()
276
+ logger.info(
277
+ f"[suite {suite_id}][timer] GE parallel "
278
+ f"({len(slow_exps)} exps, {ge_workers} workers): "
279
+ f"{_fmt(time.perf_counter() - t_suite)}"
280
+ )
281
+
282
+ return fast_raw + ge_raw
spark_dq/utils.py ADDED
@@ -0,0 +1,32 @@
1
+ from datetime import date, datetime, timedelta
2
+ from decimal import Decimal
3
+ from enum import Enum
4
+
5
+ from great_expectations.core.expectation_configuration import ExpectationConfiguration
6
+ from great_expectations.core.expectation_validation_result import (
7
+ ExpectationValidationResult,
8
+ )
9
+
10
+ datetime_format = "%Y-%m-%d %H:%M:%S"
11
+
12
+
13
+ def jsonify(data):
14
+ if isinstance(data, dict):
15
+ return {key: jsonify(value) for key, value in data.items()}
16
+ elif isinstance(data, list):
17
+ return [jsonify(list_obj) for list_obj in data]
18
+ elif isinstance(data, datetime):
19
+ return data.strftime(datetime_format)
20
+ elif isinstance(data, date):
21
+ return data.isoformat()
22
+ elif isinstance(data, timedelta):
23
+ return data.total_seconds()
24
+ elif isinstance(data, (ExpectationConfiguration, ExpectationValidationResult)):
25
+ return {key: jsonify(value) for key, value in dict(data).items()}
26
+ elif isinstance(data, Decimal):
27
+ return float(data)
28
+ elif isinstance(data, Enum):
29
+ return data.value
30
+ elif data is None:
31
+ return data
32
+ return data