dequa-sdk 1.0.0__tar.gz
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.
- dequa_sdk-1.0.0/PKG-INFO +115 -0
- dequa_sdk-1.0.0/README.md +85 -0
- dequa_sdk-1.0.0/__init__.py +10 -0
- dequa_sdk-1.0.0/client.py +42 -0
- dequa_sdk-1.0.0/dequa_sdk.egg-info/PKG-INFO +115 -0
- dequa_sdk-1.0.0/dequa_sdk.egg-info/SOURCES.txt +23 -0
- dequa_sdk-1.0.0/dequa_sdk.egg-info/dependency_links.txt +1 -0
- dequa_sdk-1.0.0/dequa_sdk.egg-info/requires.txt +14 -0
- dequa_sdk-1.0.0/dequa_sdk.egg-info/top_level.txt +1 -0
- dequa_sdk-1.0.0/integrations/__init__.py +8 -0
- dequa_sdk-1.0.0/integrations/airflow.py +85 -0
- dequa_sdk-1.0.0/integrations/dbt.py +80 -0
- dequa_sdk-1.0.0/magics.py +67 -0
- dequa_sdk-1.0.0/pyproject.toml +48 -0
- dequa_sdk-1.0.0/quarantine.py +44 -0
- dequa_sdk-1.0.0/setup.cfg +4 -0
- dequa_sdk-1.0.0/validators.py +150 -0
dequa_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dequa-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Native Python SDK & Integrations for Dequa ETL Data Quality Engine
|
|
5
|
+
Author-email: Dequa ETL Team <support@dequa-etl.io>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/dequa-etl/dequa-etl-tool
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/dequa-etl/dequa-etl-tool/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/dequa-etl/dequa-etl-tool#readme
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: requests>=2.28.0
|
|
20
|
+
Requires-Dist: pandas>=1.5.0
|
|
21
|
+
Requires-Dist: duckdb>=0.9.0
|
|
22
|
+
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Provides-Extra: airflow
|
|
24
|
+
Requires-Dist: apache-airflow>=2.4.0; extra == "airflow"
|
|
25
|
+
Provides-Extra: dbt
|
|
26
|
+
Requires-Dist: dbt-core>=1.5.0; extra == "dbt"
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: apache-airflow>=2.4.0; extra == "all"
|
|
29
|
+
Requires-Dist: dbt-core>=1.5.0; extra == "all"
|
|
30
|
+
|
|
31
|
+
# ⚡ Dequa Python SDK
|
|
32
|
+
|
|
33
|
+
The official Python client and data validation SDK for **Dequa ETL**.
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/dequa-sdk/)
|
|
36
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 📦 Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install dequa-sdk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
With optional integrations:
|
|
47
|
+
```bash
|
|
48
|
+
# Airflow integration
|
|
49
|
+
pip install dequa-sdk[airflow]
|
|
50
|
+
|
|
51
|
+
# dbt Core integration
|
|
52
|
+
pip install dequa-sdk[dbt]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 🚀 Quick Usage
|
|
58
|
+
|
|
59
|
+
### 1. Vectorized In-Memory DataFrame Reconciliation
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import pandas as pd
|
|
63
|
+
from dequa_sdk import validate
|
|
64
|
+
|
|
65
|
+
source_df = pd.read_csv("bronze_orders.csv")
|
|
66
|
+
target_df = pd.read_csv("gold_orders.csv")
|
|
67
|
+
|
|
68
|
+
# Run zero-config reconciliation
|
|
69
|
+
report = validate(
|
|
70
|
+
source=source_df,
|
|
71
|
+
target=target_df,
|
|
72
|
+
key_columns=["order_id"],
|
|
73
|
+
compare_columns=["amount", "status"]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
print(report.summary())
|
|
77
|
+
if report.has_discrepancies():
|
|
78
|
+
print("Mismatched records found:", report.value_mismatches)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### 2. Airflow DAG Gating Operator
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from airflow import DAG
|
|
87
|
+
from dequa_sdk.integrations.airflow import DequaCheckOperator
|
|
88
|
+
from datetime import datetime
|
|
89
|
+
|
|
90
|
+
with DAG("daily_orders_pipeline", start_date=datetime(2026, 1, 1)) as dag:
|
|
91
|
+
assert_orders = DequaCheckOperator(
|
|
92
|
+
task_id="assert_orders_reconciliation",
|
|
93
|
+
pipeline_id=1,
|
|
94
|
+
dequa_url="http://dequa-backend:8000",
|
|
95
|
+
fail_on_mismatch=True
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### 3. dbt Manifest Parser
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from dequa_sdk.integrations.dbt import DbtManifestParser
|
|
105
|
+
|
|
106
|
+
parser = DbtManifestParser(manifest_path="target/manifest.json")
|
|
107
|
+
models = parser.extract_models()
|
|
108
|
+
|
|
109
|
+
for model in models:
|
|
110
|
+
config = parser.generate_pipeline_config(
|
|
111
|
+
model_name=model["model_name"],
|
|
112
|
+
source_conn_id=1,
|
|
113
|
+
target_conn_id=2
|
|
114
|
+
)
|
|
115
|
+
```
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# ⚡ Dequa Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python client and data validation SDK for **Dequa ETL**.
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/dequa-sdk/)
|
|
6
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 📦 Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install dequa-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
With optional integrations:
|
|
17
|
+
```bash
|
|
18
|
+
# Airflow integration
|
|
19
|
+
pip install dequa-sdk[airflow]
|
|
20
|
+
|
|
21
|
+
# dbt Core integration
|
|
22
|
+
pip install dequa-sdk[dbt]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 🚀 Quick Usage
|
|
28
|
+
|
|
29
|
+
### 1. Vectorized In-Memory DataFrame Reconciliation
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import pandas as pd
|
|
33
|
+
from dequa_sdk import validate
|
|
34
|
+
|
|
35
|
+
source_df = pd.read_csv("bronze_orders.csv")
|
|
36
|
+
target_df = pd.read_csv("gold_orders.csv")
|
|
37
|
+
|
|
38
|
+
# Run zero-config reconciliation
|
|
39
|
+
report = validate(
|
|
40
|
+
source=source_df,
|
|
41
|
+
target=target_df,
|
|
42
|
+
key_columns=["order_id"],
|
|
43
|
+
compare_columns=["amount", "status"]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
print(report.summary())
|
|
47
|
+
if report.has_discrepancies():
|
|
48
|
+
print("Mismatched records found:", report.value_mismatches)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
### 2. Airflow DAG Gating Operator
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from airflow import DAG
|
|
57
|
+
from dequa_sdk.integrations.airflow import DequaCheckOperator
|
|
58
|
+
from datetime import datetime
|
|
59
|
+
|
|
60
|
+
with DAG("daily_orders_pipeline", start_date=datetime(2026, 1, 1)) as dag:
|
|
61
|
+
assert_orders = DequaCheckOperator(
|
|
62
|
+
task_id="assert_orders_reconciliation",
|
|
63
|
+
pipeline_id=1,
|
|
64
|
+
dequa_url="http://dequa-backend:8000",
|
|
65
|
+
fail_on_mismatch=True
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
### 3. dbt Manifest Parser
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from dequa_sdk.integrations.dbt import DbtManifestParser
|
|
75
|
+
|
|
76
|
+
parser = DbtManifestParser(manifest_path="target/manifest.json")
|
|
77
|
+
models = parser.extract_models()
|
|
78
|
+
|
|
79
|
+
for model in models:
|
|
80
|
+
config = parser.generate_pipeline_config(
|
|
81
|
+
model_name=model["model_name"],
|
|
82
|
+
source_conn_id=1,
|
|
83
|
+
target_conn_id=2
|
|
84
|
+
)
|
|
85
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dequa SDK — Native Data Quality & Validation Engine
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dequa_sdk.client import Client
|
|
6
|
+
from dequa_sdk.validators import validate, ValidationReport
|
|
7
|
+
from dequa_sdk.quarantine import QuarantineRouter
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
__all__ = ["Client", "validate", "ValidationReport", "QuarantineRouter"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dequa SDK Client Core
|
|
3
|
+
|
|
4
|
+
Client interface for connecting Python data pipelines, PySpark jobs, and notebooks
|
|
5
|
+
to the Dequa ETL backend control plane.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
import os
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Client:
|
|
14
|
+
"""Dequa SDK Client for pipeline validation and observability sync."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: Optional[str] = None,
|
|
19
|
+
control_plane_url: Optional[str] = None,
|
|
20
|
+
offline_mode: bool = False
|
|
21
|
+
):
|
|
22
|
+
self.api_key = api_key or os.getenv("DEQUA_API_KEY", "")
|
|
23
|
+
self.control_plane_url = (control_plane_url or os.getenv("DEQUA_API_URL", "http://localhost:8000/api/v1")).rstrip("/")
|
|
24
|
+
self.offline_mode = offline_mode
|
|
25
|
+
|
|
26
|
+
def _headers(self) -> Dict[str, str]:
|
|
27
|
+
headers = {"Content-Type": "application/json"}
|
|
28
|
+
if self.api_key:
|
|
29
|
+
headers["X-API-Key"] = self.api_key
|
|
30
|
+
return headers
|
|
31
|
+
|
|
32
|
+
def sync_validation_report(self, pipeline_id: str, report_data: Dict[str, Any]) -> bool:
|
|
33
|
+
"""Sends inline SDK validation results to Dequa Control Plane."""
|
|
34
|
+
if self.offline_mode:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
endpoint = f"{self.control_plane_url}/pipelines/{pipeline_id}/sdk-report"
|
|
38
|
+
try:
|
|
39
|
+
resp = requests.post(endpoint, json=report_data, headers=self._headers(), timeout=10)
|
|
40
|
+
return resp.status_code in [200, 201]
|
|
41
|
+
except Exception:
|
|
42
|
+
return False
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dequa-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Native Python SDK & Integrations for Dequa ETL Data Quality Engine
|
|
5
|
+
Author-email: Dequa ETL Team <support@dequa-etl.io>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/dequa-etl/dequa-etl-tool
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/dequa-etl/dequa-etl-tool/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/dequa-etl/dequa-etl-tool#readme
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: requests>=2.28.0
|
|
20
|
+
Requires-Dist: pandas>=1.5.0
|
|
21
|
+
Requires-Dist: duckdb>=0.9.0
|
|
22
|
+
Requires-Dist: pydantic>=2.0.0
|
|
23
|
+
Provides-Extra: airflow
|
|
24
|
+
Requires-Dist: apache-airflow>=2.4.0; extra == "airflow"
|
|
25
|
+
Provides-Extra: dbt
|
|
26
|
+
Requires-Dist: dbt-core>=1.5.0; extra == "dbt"
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: apache-airflow>=2.4.0; extra == "all"
|
|
29
|
+
Requires-Dist: dbt-core>=1.5.0; extra == "all"
|
|
30
|
+
|
|
31
|
+
# ⚡ Dequa Python SDK
|
|
32
|
+
|
|
33
|
+
The official Python client and data validation SDK for **Dequa ETL**.
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/dequa-sdk/)
|
|
36
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 📦 Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install dequa-sdk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
With optional integrations:
|
|
47
|
+
```bash
|
|
48
|
+
# Airflow integration
|
|
49
|
+
pip install dequa-sdk[airflow]
|
|
50
|
+
|
|
51
|
+
# dbt Core integration
|
|
52
|
+
pip install dequa-sdk[dbt]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 🚀 Quick Usage
|
|
58
|
+
|
|
59
|
+
### 1. Vectorized In-Memory DataFrame Reconciliation
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import pandas as pd
|
|
63
|
+
from dequa_sdk import validate
|
|
64
|
+
|
|
65
|
+
source_df = pd.read_csv("bronze_orders.csv")
|
|
66
|
+
target_df = pd.read_csv("gold_orders.csv")
|
|
67
|
+
|
|
68
|
+
# Run zero-config reconciliation
|
|
69
|
+
report = validate(
|
|
70
|
+
source=source_df,
|
|
71
|
+
target=target_df,
|
|
72
|
+
key_columns=["order_id"],
|
|
73
|
+
compare_columns=["amount", "status"]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
print(report.summary())
|
|
77
|
+
if report.has_discrepancies():
|
|
78
|
+
print("Mismatched records found:", report.value_mismatches)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### 2. Airflow DAG Gating Operator
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from airflow import DAG
|
|
87
|
+
from dequa_sdk.integrations.airflow import DequaCheckOperator
|
|
88
|
+
from datetime import datetime
|
|
89
|
+
|
|
90
|
+
with DAG("daily_orders_pipeline", start_date=datetime(2026, 1, 1)) as dag:
|
|
91
|
+
assert_orders = DequaCheckOperator(
|
|
92
|
+
task_id="assert_orders_reconciliation",
|
|
93
|
+
pipeline_id=1,
|
|
94
|
+
dequa_url="http://dequa-backend:8000",
|
|
95
|
+
fail_on_mismatch=True
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### 3. dbt Manifest Parser
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from dequa_sdk.integrations.dbt import DbtManifestParser
|
|
105
|
+
|
|
106
|
+
parser = DbtManifestParser(manifest_path="target/manifest.json")
|
|
107
|
+
models = parser.extract_models()
|
|
108
|
+
|
|
109
|
+
for model in models:
|
|
110
|
+
config = parser.generate_pipeline_config(
|
|
111
|
+
model_name=model["model_name"],
|
|
112
|
+
source_conn_id=1,
|
|
113
|
+
target_conn_id=2
|
|
114
|
+
)
|
|
115
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
__init__.py
|
|
3
|
+
client.py
|
|
4
|
+
magics.py
|
|
5
|
+
pyproject.toml
|
|
6
|
+
quarantine.py
|
|
7
|
+
validators.py
|
|
8
|
+
./__init__.py
|
|
9
|
+
./client.py
|
|
10
|
+
./magics.py
|
|
11
|
+
./quarantine.py
|
|
12
|
+
./validators.py
|
|
13
|
+
./integrations/__init__.py
|
|
14
|
+
./integrations/airflow.py
|
|
15
|
+
./integrations/dbt.py
|
|
16
|
+
dequa_sdk.egg-info/PKG-INFO
|
|
17
|
+
dequa_sdk.egg-info/SOURCES.txt
|
|
18
|
+
dequa_sdk.egg-info/dependency_links.txt
|
|
19
|
+
dequa_sdk.egg-info/requires.txt
|
|
20
|
+
dequa_sdk.egg-info/top_level.txt
|
|
21
|
+
integrations/__init__.py
|
|
22
|
+
integrations/airflow.py
|
|
23
|
+
integrations/dbt.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dequa_sdk
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dequa ETL — Apache Airflow Integration Operator
|
|
3
|
+
Allows seamless gating and automated quality checks within Airflow DAGs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional, List, Dict, Any
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from airflow.models import BaseOperator
|
|
11
|
+
from airflow.exceptions import AirflowException
|
|
12
|
+
except ImportError:
|
|
13
|
+
# Graceful fallback for non-Airflow execution environments
|
|
14
|
+
class BaseOperator:
|
|
15
|
+
def __init__(self, **kwargs):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
class AirflowException(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
from dequa_sdk.client import Client
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DequaCheckOperator(BaseOperator):
|
|
25
|
+
"""
|
|
26
|
+
Airflow Operator that triggers a Dequa pipeline validation and asserts quality SLA.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
from dequa_sdk.integrations.airflow import DequaCheckOperator
|
|
30
|
+
|
|
31
|
+
check_orders = DequaCheckOperator(
|
|
32
|
+
task_id="assert_orders_reconciliation",
|
|
33
|
+
pipeline_id=1,
|
|
34
|
+
fail_on_mismatch=True,
|
|
35
|
+
dequa_url="http://dequa-backend:8000",
|
|
36
|
+
api_key="your_api_key",
|
|
37
|
+
)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
pipeline_id: int,
|
|
43
|
+
dequa_url: str = "http://localhost:8000",
|
|
44
|
+
api_key: Optional[str] = None,
|
|
45
|
+
fail_on_mismatch: bool = True,
|
|
46
|
+
timeout_seconds: int = 300,
|
|
47
|
+
poll_interval: int = 5,
|
|
48
|
+
**kwargs
|
|
49
|
+
):
|
|
50
|
+
super().__init__(**kwargs)
|
|
51
|
+
self.pipeline_id = pipeline_id
|
|
52
|
+
self.dequa_url = dequa_url
|
|
53
|
+
self.api_key = api_key
|
|
54
|
+
self.fail_on_mismatch = fail_on_mismatch
|
|
55
|
+
self.timeout_seconds = timeout_seconds
|
|
56
|
+
self.poll_interval = poll_interval
|
|
57
|
+
|
|
58
|
+
def execute(self, context: Any) -> Dict[str, Any]:
|
|
59
|
+
self.log.info(f"Triggering Dequa Pipeline ID: {self.pipeline_id}")
|
|
60
|
+
client = Client(base_url=self.dequa_url, api_key=self.api_key)
|
|
61
|
+
|
|
62
|
+
job = client.trigger_pipeline(self.pipeline_id)
|
|
63
|
+
job_id = job.get("id") or job.get("job_id")
|
|
64
|
+
self.log.info(f"Dequa Job {job_id} initiated. Polling for completion...")
|
|
65
|
+
|
|
66
|
+
start_time = time.time()
|
|
67
|
+
while time.time() - start_time < self.timeout_seconds:
|
|
68
|
+
status_data = client.get_job_status(job_id)
|
|
69
|
+
status = status_data.get("status")
|
|
70
|
+
|
|
71
|
+
if status in ("passed", "success"):
|
|
72
|
+
self.log.info(f"Dequa Job {job_id} PASSED with 100% record parity.")
|
|
73
|
+
return status_data
|
|
74
|
+
elif status in ("failed", "warning"):
|
|
75
|
+
mismatch_count = status_data.get("value_mismatches_count", 0) + status_data.get("missing_in_target_count", 0)
|
|
76
|
+
msg = f"Dequa Job {job_id} failed with {mismatch_count} discrepancies."
|
|
77
|
+
if self.fail_on_mismatch:
|
|
78
|
+
raise AirflowException(msg)
|
|
79
|
+
else:
|
|
80
|
+
self.log.warning(msg)
|
|
81
|
+
return status_data
|
|
82
|
+
|
|
83
|
+
time.sleep(self.poll_interval)
|
|
84
|
+
|
|
85
|
+
raise AirflowException(f"Dequa Job {job_id} timed out after {self.timeout_seconds} seconds.")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dequa ETL — dbt Core Integration
|
|
3
|
+
Parses dbt manifest artifacts (manifest.json, catalog.json) to automatically generate
|
|
4
|
+
Dequa schema reconciliation pipelines and assertions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from typing import Dict, List, Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DbtManifestParser:
|
|
13
|
+
"""
|
|
14
|
+
Introspects compiled dbt project manifests to extract models, primary keys,
|
|
15
|
+
and column schemas for automated Dequa pipeline generation.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, manifest_path: str, catalog_path: Optional[str] = None):
|
|
19
|
+
self.manifest_path = manifest_path
|
|
20
|
+
self.catalog_path = catalog_path
|
|
21
|
+
self.manifest_data = self._load_json(manifest_path)
|
|
22
|
+
self.catalog_data = self._load_json(catalog_path) if catalog_path and os.path.exists(catalog_path) else {}
|
|
23
|
+
|
|
24
|
+
def _load_json(self, path: str) -> Dict[str, Any]:
|
|
25
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
|
|
28
|
+
def extract_models(self) -> List[Dict[str, Any]]:
|
|
29
|
+
"""
|
|
30
|
+
Extracts all model definitions and unique keys from the dbt manifest.
|
|
31
|
+
"""
|
|
32
|
+
nodes = self.manifest_data.get("nodes", {})
|
|
33
|
+
models = []
|
|
34
|
+
|
|
35
|
+
for node_id, node in nodes.items():
|
|
36
|
+
if node.get("resource_type") == "model":
|
|
37
|
+
name = node.get("name")
|
|
38
|
+
schema = node.get("schema")
|
|
39
|
+
database = node.get("database")
|
|
40
|
+
alias = node.get("alias", name)
|
|
41
|
+
|
|
42
|
+
# Extract primary/unique keys if defined in dbt tests or meta
|
|
43
|
+
columns = list(node.get("columns", {}).keys())
|
|
44
|
+
meta = node.get("meta", {})
|
|
45
|
+
primary_keys = meta.get("primary_key", [])
|
|
46
|
+
if isinstance(primary_keys, str):
|
|
47
|
+
primary_keys = [primary_keys]
|
|
48
|
+
|
|
49
|
+
models.append({
|
|
50
|
+
"model_name": name,
|
|
51
|
+
"target_table": f"{schema}.{alias}" if schema else alias,
|
|
52
|
+
"database": database,
|
|
53
|
+
"columns": columns,
|
|
54
|
+
"primary_keys": primary_keys,
|
|
55
|
+
"tags": node.get("tags", []),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return models
|
|
59
|
+
|
|
60
|
+
def generate_pipeline_config(self, model_name: str, source_conn_id: int, target_conn_id: int) -> Optional[Dict[str, Any]]:
|
|
61
|
+
"""
|
|
62
|
+
Generates a complete Dequa pipeline configuration dict for a specific dbt model.
|
|
63
|
+
"""
|
|
64
|
+
models = self.extract_models()
|
|
65
|
+
target = next((m for m in models if m["model_name"] == model_name), None)
|
|
66
|
+
if not target:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
primary_keys = target["primary_keys"] or (["id"] if "id" in target["columns"] else target["columns"][:1])
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"name": f"dbt Reconciliation: {model_name}",
|
|
73
|
+
"source_conn_id": source_conn_id,
|
|
74
|
+
"target_conn_id": target_conn_id,
|
|
75
|
+
"source_table": f"stg_{model_name}",
|
|
76
|
+
"target_table": target["target_table"],
|
|
77
|
+
"key_columns": primary_keys,
|
|
78
|
+
"compare_columns": [{"source": c, "target": c} for c in target["columns"] if c not in primary_keys],
|
|
79
|
+
"tags": ["dbt-generated", *target["tags"]],
|
|
80
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dequa IPython Magic Extensions for Jupyter & Databricks Notebooks
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from IPython.core.magic import Magics, magics_class, line_magic, cell_magic
|
|
10
|
+
except ImportError:
|
|
11
|
+
# Graceful fallback if IPython is not installed
|
|
12
|
+
class Magics:
|
|
13
|
+
pass
|
|
14
|
+
def magics_class(cls): return cls
|
|
15
|
+
def line_magic(func): return func
|
|
16
|
+
def cell_magic(func): return func
|
|
17
|
+
|
|
18
|
+
from dequa_sdk.validators import validate
|
|
19
|
+
from dequa_sdk.quarantine import QuarantineRouter
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@magics_class
|
|
23
|
+
class DequaMagics(Magics):
|
|
24
|
+
"""IPython magic commands for interactive data quality testing."""
|
|
25
|
+
|
|
26
|
+
@line_magic
|
|
27
|
+
def dequa_profile(self, line: str):
|
|
28
|
+
"""Line magic to quickly profile a pandas dataframe variable: %dequa_profile my_df"""
|
|
29
|
+
var_name = line.strip()
|
|
30
|
+
if not var_name or var_name not in self.shell.user_ns:
|
|
31
|
+
print(f"Error: Variable '{var_name}' not found in notebook namespace.")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
val = self.shell.user_ns[var_name]
|
|
35
|
+
if isinstance(val, pd.DataFrame):
|
|
36
|
+
print(f"━━━ Dequa Profile: {var_name} ━━━")
|
|
37
|
+
print(f"Shape: {val.shape[0]} rows × {val.shape[1]} columns")
|
|
38
|
+
print(f"Null Cells Total: {val.isna().sum().sum()}")
|
|
39
|
+
print(f"Duplicate Rows: {val.duplicated().sum()}")
|
|
40
|
+
print("\nColumn Null Ratios:")
|
|
41
|
+
print((val.isna().mean() * 100.0).round(2).to_string())
|
|
42
|
+
|
|
43
|
+
@cell_magic
|
|
44
|
+
def dequa_test(self, line: str, cell: str):
|
|
45
|
+
"""Cell magic to run assertions on a dataframe variable."""
|
|
46
|
+
var_name = line.strip()
|
|
47
|
+
if not var_name or var_name not in self.shell.user_ns:
|
|
48
|
+
print(f"Error: Variable '{var_name}' not found.")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
df = self.shell.user_ns[var_name]
|
|
52
|
+
rules = [line.strip() for line in cell.strip().split("\n") if line.strip() and not line.startswith("#")]
|
|
53
|
+
|
|
54
|
+
report = validate(df, rules, dataset_name=var_name)
|
|
55
|
+
print(f"━━━ Dequa Validation Results for '{var_name}' ━━━")
|
|
56
|
+
print(f"Status: {'PASS ✅' if report.is_success else 'FAIL ❌'}")
|
|
57
|
+
print(f"Pass Rate: {report.pass_rate}% ({len(report.passed_rules)}/{len(rules)} rules passed)")
|
|
58
|
+
|
|
59
|
+
if report.failed_rules:
|
|
60
|
+
print(f"\nFailed Rules ({len(report.failed_rules)}):")
|
|
61
|
+
for r in report.failed_rules:
|
|
62
|
+
print(f" - ❌ {r}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_ipython_extension(ipython):
|
|
66
|
+
"""Entry point for %load_ext dequa_sdk.magics"""
|
|
67
|
+
ipython.register_magics(DequaMagics)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dequa-sdk"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Native Python SDK & Integrations for Dequa ETL Data Quality Engine"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Dequa ETL Team", email = "support@dequa-etl.io" }]
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Database",
|
|
19
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.9"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"requests>=2.28.0",
|
|
24
|
+
"pandas>=1.5.0",
|
|
25
|
+
"duckdb>=0.9.0",
|
|
26
|
+
"pydantic>=2.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
airflow = [
|
|
31
|
+
"apache-airflow>=2.4.0",
|
|
32
|
+
]
|
|
33
|
+
dbt = [
|
|
34
|
+
"dbt-core>=1.5.0",
|
|
35
|
+
]
|
|
36
|
+
all = [
|
|
37
|
+
"apache-airflow>=2.4.0",
|
|
38
|
+
"dbt-core>=1.5.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools]
|
|
42
|
+
packages = ["dequa_sdk", "dequa_sdk.integrations"]
|
|
43
|
+
package-dir = {"dequa_sdk" = "."}
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
"Homepage" = "https://github.com/dequa-etl/dequa-etl-tool"
|
|
47
|
+
"Bug Tracker" = "https://github.com/dequa-etl/dequa-etl-tool/issues"
|
|
48
|
+
"Documentation" = "https://github.com/dequa-etl/dequa-etl-tool#readme"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dequa SDK Dynamic Quarantine Router
|
|
3
|
+
|
|
4
|
+
Splits clean rows from corrupted rows violating validation assertions,
|
|
5
|
+
routing invalid records to quarantine storage (DataFrame, CSV, or DB table).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Tuple
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from dequa_sdk.validators import ValidationReport
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QuarantineRouter:
|
|
14
|
+
"""Routes invalid records into quarantine isolated data streams."""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def route(df: Any, report: ValidationReport) -> Tuple[pd.DataFrame, pd.DataFrame]:
|
|
18
|
+
"""
|
|
19
|
+
Splits input DataFrame into (clean_df, quarantined_df).
|
|
20
|
+
"""
|
|
21
|
+
if hasattr(df, "toPandas"):
|
|
22
|
+
pdf = df.toPandas()
|
|
23
|
+
elif isinstance(df, pd.DataFrame):
|
|
24
|
+
pdf = df
|
|
25
|
+
else:
|
|
26
|
+
pdf = pd.DataFrame(df)
|
|
27
|
+
|
|
28
|
+
if not report.quarantine_indices:
|
|
29
|
+
return pdf, pd.DataFrame(columns=pdf.columns)
|
|
30
|
+
|
|
31
|
+
quarantine_set = set(report.quarantine_indices)
|
|
32
|
+
clean_mask = ~pdf.index.isin(quarantine_set)
|
|
33
|
+
quarantine_mask = pdf.index.isin(quarantine_set)
|
|
34
|
+
|
|
35
|
+
clean_df = pdf[clean_mask].copy()
|
|
36
|
+
quarantined_df = pdf[quarantine_mask].copy()
|
|
37
|
+
|
|
38
|
+
return clean_df, quarantined_df
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def export_quarantine(quarantined_df: pd.DataFrame, output_path: str) -> str:
|
|
42
|
+
"""Exports quarantined records to CSV file path."""
|
|
43
|
+
quarantined_df.to_csv(output_path, index=False)
|
|
44
|
+
return output_path
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dequa SDK DataFrame Validation Engine
|
|
3
|
+
|
|
4
|
+
Provides inline validation rules for Pandas, Polars, and PySpark DataFrames.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
8
|
+
import re
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ValidationReport:
|
|
13
|
+
"""Validation execution result details."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, dataset_name: str, total_rows: int):
|
|
16
|
+
self.dataset_name = dataset_name
|
|
17
|
+
self.total_rows = total_rows
|
|
18
|
+
self.passed_rules: List[str] = []
|
|
19
|
+
self.failed_rules: List[str] = []
|
|
20
|
+
self.rule_details: List[Dict[str, Any]] = []
|
|
21
|
+
self.quarantine_indices: List[int] = []
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_success(self) -> bool:
|
|
25
|
+
return len(self.failed_rules) == 0
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def pass_rate(self) -> float:
|
|
29
|
+
total = len(self.passed_rules) + len(self.failed_rules)
|
|
30
|
+
return (len(self.passed_rules) / total * 100.0) if total > 0 else 100.0
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
"dataset_name": self.dataset_name,
|
|
35
|
+
"total_rows": self.total_rows,
|
|
36
|
+
"is_success": self.is_success,
|
|
37
|
+
"pass_rate": round(self.pass_rate, 2),
|
|
38
|
+
"passed_rules_count": len(self.passed_rules),
|
|
39
|
+
"failed_rules_count": len(self.failed_rules),
|
|
40
|
+
"failed_rules": self.failed_rules,
|
|
41
|
+
"rule_details": self.rule_details,
|
|
42
|
+
"quarantined_rows_count": len(self.quarantine_indices)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_rule(rule_str: str) -> Tuple[str, List[str]]:
|
|
47
|
+
"""Parse rule strings like 'not_null(customer_id)' into ('not_null', ['customer_id'])."""
|
|
48
|
+
match = re.match(r"^(\w+)\((.*)\)$", rule_str.strip())
|
|
49
|
+
if not match:
|
|
50
|
+
return rule_str.strip(), []
|
|
51
|
+
func = match.group(1).lower()
|
|
52
|
+
args_raw = match.group(2)
|
|
53
|
+
args = [arg.strip().strip("'\"") for arg in args_raw.split(",") if arg.strip()]
|
|
54
|
+
return func, args
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate(
|
|
58
|
+
df: Any,
|
|
59
|
+
rules: List[str],
|
|
60
|
+
dataset_name: str = "dataset",
|
|
61
|
+
on_failure: str = "warn"
|
|
62
|
+
) -> ValidationReport:
|
|
63
|
+
"""
|
|
64
|
+
Validate a DataFrame against assertion rules.
|
|
65
|
+
|
|
66
|
+
:param df: Pandas DataFrame or PySpark DataFrame
|
|
67
|
+
:param rules: List of rule strings, e.g. ["not_null(id)", "min_value(amount, 0)", "unique(id)"]
|
|
68
|
+
:param dataset_name: Name identifier for the dataset
|
|
69
|
+
:param on_failure: Strategy when validation fails ("warn", "raise", "quarantine")
|
|
70
|
+
"""
|
|
71
|
+
total_rows = len(df) if hasattr(df, "__len__") else 0
|
|
72
|
+
report = ValidationReport(dataset_name=dataset_name, total_rows=total_rows)
|
|
73
|
+
|
|
74
|
+
# Convert to Pandas if PySpark DataFrame passed
|
|
75
|
+
if hasattr(df, "toPandas"):
|
|
76
|
+
pdf = df.toPandas()
|
|
77
|
+
elif isinstance(df, pd.DataFrame):
|
|
78
|
+
pdf = df
|
|
79
|
+
else:
|
|
80
|
+
pdf = pd.DataFrame(df)
|
|
81
|
+
|
|
82
|
+
quarantine_indices = set()
|
|
83
|
+
|
|
84
|
+
for rule_str in rules:
|
|
85
|
+
func_name, args = _parse_rule(rule_str)
|
|
86
|
+
is_passed = True
|
|
87
|
+
failed_count = 0
|
|
88
|
+
|
|
89
|
+
if func_name == "not_null" and args:
|
|
90
|
+
col = args[0]
|
|
91
|
+
if col in pdf.columns:
|
|
92
|
+
null_mask = pdf[col].isna()
|
|
93
|
+
failed_count = int(null_mask.sum())
|
|
94
|
+
if failed_count > 0:
|
|
95
|
+
is_passed = False
|
|
96
|
+
quarantine_indices.update(pdf[null_mask].index.tolist())
|
|
97
|
+
|
|
98
|
+
elif func_name == "min_value" and len(args) >= 2:
|
|
99
|
+
col, min_val = args[0], float(args[1])
|
|
100
|
+
if col in pdf.columns:
|
|
101
|
+
min_mask = pdf[col] < min_val
|
|
102
|
+
failed_count = int(min_mask.sum())
|
|
103
|
+
if failed_count > 0:
|
|
104
|
+
is_passed = False
|
|
105
|
+
quarantine_indices.update(pdf[min_mask].index.tolist())
|
|
106
|
+
|
|
107
|
+
elif func_name == "max_value" and len(args) >= 2:
|
|
108
|
+
col, max_val = args[0], float(args[1])
|
|
109
|
+
if col in pdf.columns:
|
|
110
|
+
max_mask = pdf[col] > max_val
|
|
111
|
+
failed_count = int(max_mask.sum())
|
|
112
|
+
if failed_count > 0:
|
|
113
|
+
is_passed = False
|
|
114
|
+
quarantine_indices.update(pdf[max_mask].index.tolist())
|
|
115
|
+
|
|
116
|
+
elif func_name == "unique" and args:
|
|
117
|
+
col = args[0]
|
|
118
|
+
if col in pdf.columns:
|
|
119
|
+
dup_mask = pdf[col].duplicated(keep=False)
|
|
120
|
+
failed_count = int(dup_mask.sum())
|
|
121
|
+
if failed_count > 0:
|
|
122
|
+
is_passed = False
|
|
123
|
+
quarantine_indices.update(pdf[dup_mask].index.tolist())
|
|
124
|
+
|
|
125
|
+
elif func_name == "regex" and len(args) >= 2:
|
|
126
|
+
col, pattern = args[0], args[1]
|
|
127
|
+
if col in pdf.columns:
|
|
128
|
+
regex_mask = ~pdf[col].astype(str).str.contains(pattern, na=False, regex=True)
|
|
129
|
+
failed_count = int(regex_mask.sum())
|
|
130
|
+
if failed_count > 0:
|
|
131
|
+
is_passed = False
|
|
132
|
+
quarantine_indices.update(pdf[regex_mask].index.tolist())
|
|
133
|
+
|
|
134
|
+
if is_passed:
|
|
135
|
+
report.passed_rules.append(rule_str)
|
|
136
|
+
else:
|
|
137
|
+
report.failed_rules.append(rule_str)
|
|
138
|
+
|
|
139
|
+
report.rule_details.append({
|
|
140
|
+
"rule": rule_str,
|
|
141
|
+
"status": "PASS" if is_passed else "FAIL",
|
|
142
|
+
"failed_records_count": failed_count
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
report.quarantine_indices = sorted(list(quarantine_indices))
|
|
146
|
+
|
|
147
|
+
if not report.is_success and on_failure == "raise":
|
|
148
|
+
raise ValueError(f"Dequa Validation Failed for '{dataset_name}': {len(report.failed_rules)} rules violated.")
|
|
149
|
+
|
|
150
|
+
return report
|