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.
- spark_data_quality-1.0.0.dist-info/METADATA +123 -0
- spark_data_quality-1.0.0.dist-info/RECORD +23 -0
- spark_data_quality-1.0.0.dist-info/WHEEL +5 -0
- spark_data_quality-1.0.0.dist-info/licenses/LICENSE +21 -0
- spark_data_quality-1.0.0.dist-info/top_level.txt +1 -0
- spark_dq/__init__.py +3 -0
- spark_dq/data_quality_helper/__init__.py +0 -0
- spark_dq/data_quality_helper/batch_request.py +23 -0
- spark_dq/data_quality_helper/checkpoint_conf.py +30 -0
- spark_dq/data_quality_helper/const.py +27 -0
- spark_dq/data_quality_helper/datacontext.py +39 -0
- spark_dq/data_quality_helper/expectation_suite.py +17 -0
- spark_dq/data_quality_helper/ge_utils.py +20 -0
- spark_dq/data_quality_helper/utils.py +50 -0
- spark_dq/helpers/__init__.py +25 -0
- spark_dq/helpers/api_client.py +121 -0
- spark_dq/helpers/exceptions.py +6 -0
- spark_dq/helpers/expectations.py +43 -0
- spark_dq/helpers/ge_executor.py +145 -0
- spark_dq/helpers/sql_utils.py +26 -0
- spark_dq/helpers/trino_executor.py +171 -0
- spark_dq/quality.py +282 -0
- spark_dq/utils.py +32 -0
|
@@ -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,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
|
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,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
|