saythu 0.1.1__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.
- saythu-0.1.1/PKG-INFO +9 -0
- saythu-0.1.1/README.md +28 -0
- saythu-0.1.1/pyproject.toml +28 -0
- saythu-0.1.1/setup.cfg +4 -0
- saythu-0.1.1/src/qa_runner/__init__.py +6 -0
- saythu-0.1.1/src/qa_runner/checks.py +239 -0
- saythu-0.1.1/src/qa_runner/executor.py +74 -0
- saythu-0.1.1/src/qa_runner/metadata_loader.py +33 -0
- saythu-0.1.1/src/qa_runner/models.py +97 -0
- saythu-0.1.1/src/qa_runner/qa_validator.py +38 -0
- saythu-0.1.1/src/qa_runner/report.py +108 -0
- saythu-0.1.1/src/qa_runner/runner.py +167 -0
- saythu-0.1.1/src/saythu.egg-info/PKG-INFO +9 -0
- saythu-0.1.1/src/saythu.egg-info/SOURCES.txt +25 -0
- saythu-0.1.1/src/saythu.egg-info/dependency_links.txt +1 -0
- saythu-0.1.1/src/saythu.egg-info/entry_points.txt +2 -0
- saythu-0.1.1/src/saythu.egg-info/requires.txt +4 -0
- saythu-0.1.1/src/saythu.egg-info/top_level.txt +2 -0
- saythu-0.1.1/src/sql_generator/__init__.py +1 -0
- saythu-0.1.1/src/sql_generator/conditions.py +87 -0
- saythu-0.1.1/src/sql_generator/enums.py +60 -0
- saythu-0.1.1/src/sql_generator/exceptions.py +58 -0
- saythu-0.1.1/src/sql_generator/expressions.py +97 -0
- saythu-0.1.1/src/sql_generator/filters.py +17 -0
- saythu-0.1.1/src/sql_generator/joins.py +94 -0
- saythu-0.1.1/src/sql_generator/models.py +513 -0
- saythu-0.1.1/src/sql_generator/sql.py +235 -0
saythu-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: saythu
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Deterministic Spark SQL generation from approved metadata
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: pydantic<3,>=2.7
|
|
7
|
+
Requires-Dist: sqlglot<26,>=25.0
|
|
8
|
+
Requires-Dist: pytest<9,>=8.0
|
|
9
|
+
Requires-Dist: hypothesis<7,>=6.0
|
saythu-0.1.1/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# sql-generator
|
|
2
|
+
|
|
3
|
+
Deterministic SQL generation from validated metadata.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install saythu
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from sql_generator.models import Metadata
|
|
15
|
+
from sql_generator.sql import build_metadata_sql
|
|
16
|
+
|
|
17
|
+
with open("metadata/customer_v1.json", "r", encoding="utf-8") as f:
|
|
18
|
+
metadata = Metadata.model_validate(f.read())
|
|
19
|
+
|
|
20
|
+
sql = build_metadata_sql(metadata)
|
|
21
|
+
print(sql)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Package contents
|
|
25
|
+
|
|
26
|
+
- `sql_generator` source package
|
|
27
|
+
- metadata validation rules in `JSON_RULES.md`
|
|
28
|
+
- tests for unit validation and generation
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "saythu"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Deterministic Spark SQL generation from approved metadata"
|
|
9
|
+
readme = ""
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pydantic>=2.7,<3",
|
|
13
|
+
"sqlglot>=25.0,<26",
|
|
14
|
+
"pytest>=8.0,<9",
|
|
15
|
+
"hypothesis>=6.0,<7",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
package-dir = {"" = "src"}
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
setu = "setu:main"
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
pythonpath = ["src"]
|
saythu-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlglot import parse_one
|
|
4
|
+
|
|
5
|
+
from qa_runner.models import CheckResult, ExecutionResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check_metadata_validity(metadata: object | None) -> CheckResult:
|
|
9
|
+
if metadata is None:
|
|
10
|
+
return CheckResult(
|
|
11
|
+
check_id="C01",
|
|
12
|
+
check_name="Metadata validity",
|
|
13
|
+
status="FAIL",
|
|
14
|
+
expected="valid metadata",
|
|
15
|
+
actual="metadata missing",
|
|
16
|
+
message="Metadata was not loaded successfully.",
|
|
17
|
+
)
|
|
18
|
+
return CheckResult(
|
|
19
|
+
check_id="C01",
|
|
20
|
+
check_name="Metadata validity",
|
|
21
|
+
status="PASS",
|
|
22
|
+
expected="valid metadata",
|
|
23
|
+
actual="valid metadata",
|
|
24
|
+
message="Metadata model validated successfully.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_sql_validity(generated_sql: str | None) -> CheckResult:
|
|
29
|
+
if not generated_sql or not generated_sql.strip():
|
|
30
|
+
return CheckResult(
|
|
31
|
+
check_id="C02",
|
|
32
|
+
check_name="SQL validity",
|
|
33
|
+
status="FAIL",
|
|
34
|
+
expected="parseable Spark SQL",
|
|
35
|
+
actual="empty SQL",
|
|
36
|
+
message="No SQL was generated for validation.",
|
|
37
|
+
)
|
|
38
|
+
try:
|
|
39
|
+
parse_one(generated_sql, dialect="spark")
|
|
40
|
+
except Exception as exc: # pragma: no cover - exercised in integration path
|
|
41
|
+
return CheckResult(
|
|
42
|
+
check_id="C02",
|
|
43
|
+
check_name="SQL validity",
|
|
44
|
+
status="FAIL",
|
|
45
|
+
expected="parseable Spark SQL",
|
|
46
|
+
actual=generated_sql[:200],
|
|
47
|
+
message=f"SQL parsing failed: {exc}",
|
|
48
|
+
)
|
|
49
|
+
return CheckResult(
|
|
50
|
+
check_id="C02",
|
|
51
|
+
check_name="SQL validity",
|
|
52
|
+
status="PASS",
|
|
53
|
+
expected="parseable Spark SQL",
|
|
54
|
+
actual="SQL parsed successfully",
|
|
55
|
+
message="Generated SQL parsed successfully with sqlglot.",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_execution_success(execution: ExecutionResult | None) -> CheckResult:
|
|
60
|
+
if execution is None:
|
|
61
|
+
return CheckResult(
|
|
62
|
+
check_id="C03",
|
|
63
|
+
check_name="Execution success",
|
|
64
|
+
status="FAIL",
|
|
65
|
+
expected="execution succeeded",
|
|
66
|
+
actual="no execution result",
|
|
67
|
+
message="No execution result was captured.",
|
|
68
|
+
)
|
|
69
|
+
if execution.status == "SUCCESS":
|
|
70
|
+
return CheckResult(
|
|
71
|
+
check_id="C03",
|
|
72
|
+
check_name="Execution success",
|
|
73
|
+
status="PASS",
|
|
74
|
+
expected="execution succeeded",
|
|
75
|
+
actual="execution succeeded",
|
|
76
|
+
message="SQL executed successfully.",
|
|
77
|
+
)
|
|
78
|
+
return CheckResult(
|
|
79
|
+
check_id="C03",
|
|
80
|
+
check_name="Execution success",
|
|
81
|
+
status="FAIL",
|
|
82
|
+
expected="execution succeeded",
|
|
83
|
+
actual=execution.status,
|
|
84
|
+
message=execution.error or "SQL execution failed.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_row_count(execution: ExecutionResult | None) -> CheckResult:
|
|
89
|
+
if execution is None or execution.status != "SUCCESS":
|
|
90
|
+
return CheckResult(
|
|
91
|
+
check_id="C04",
|
|
92
|
+
check_name="Row count check",
|
|
93
|
+
status="SKIPPED",
|
|
94
|
+
expected="row_count available",
|
|
95
|
+
actual="execution not successful",
|
|
96
|
+
message="Row-count validation was skipped because the query did not complete successfully.",
|
|
97
|
+
)
|
|
98
|
+
return CheckResult(
|
|
99
|
+
check_id="C04",
|
|
100
|
+
check_name="Row count check",
|
|
101
|
+
status="PASS",
|
|
102
|
+
expected="row count capture",
|
|
103
|
+
actual=execution.row_count,
|
|
104
|
+
message="Row count was captured successfully.",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_required_columns(metadata: object | None) -> CheckResult:
|
|
109
|
+
if metadata is None:
|
|
110
|
+
return CheckResult(
|
|
111
|
+
check_id="C05",
|
|
112
|
+
check_name="Required columns",
|
|
113
|
+
status="FAIL",
|
|
114
|
+
expected="all required columns declared",
|
|
115
|
+
actual="metadata unavailable",
|
|
116
|
+
message="Required-column validation could not run because metadata was unavailable.",
|
|
117
|
+
)
|
|
118
|
+
return CheckResult(
|
|
119
|
+
check_id="C05",
|
|
120
|
+
check_name="Required columns",
|
|
121
|
+
status="PASS",
|
|
122
|
+
expected="all required columns declared",
|
|
123
|
+
actual="required columns present",
|
|
124
|
+
message="Required columns were resolved successfully from metadata.",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_nulls(metadata: object | None) -> CheckResult:
|
|
129
|
+
if metadata is None:
|
|
130
|
+
return CheckResult(
|
|
131
|
+
check_id="C06",
|
|
132
|
+
check_name="Null checks",
|
|
133
|
+
status="SKIPPED",
|
|
134
|
+
expected="null checks where rules exist",
|
|
135
|
+
actual="metadata unavailable",
|
|
136
|
+
message="Null validation was not applicable because the metadata was unavailable.",
|
|
137
|
+
)
|
|
138
|
+
return CheckResult(
|
|
139
|
+
check_id="C06",
|
|
140
|
+
check_name="Null checks",
|
|
141
|
+
status="SKIPPED",
|
|
142
|
+
expected="null checks where rules exist",
|
|
143
|
+
actual="no null-check rules supplied",
|
|
144
|
+
message="No explicit null-check rules were supplied in the current metadata contract.",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def check_duplicates(metadata: object | None) -> CheckResult:
|
|
149
|
+
if metadata is None:
|
|
150
|
+
return CheckResult(
|
|
151
|
+
check_id="C07",
|
|
152
|
+
check_name="Duplicate checks",
|
|
153
|
+
status="SKIPPED",
|
|
154
|
+
expected="target-grain uniqueness validation",
|
|
155
|
+
actual="metadata unavailable",
|
|
156
|
+
message="Duplicate validation could not run without metadata.",
|
|
157
|
+
)
|
|
158
|
+
return CheckResult(
|
|
159
|
+
check_id="C07",
|
|
160
|
+
check_name="Duplicate checks",
|
|
161
|
+
status="SKIPPED",
|
|
162
|
+
expected="target-grain uniqueness validation",
|
|
163
|
+
actual="not triggered",
|
|
164
|
+
message="Duplicate checks are not automatically enforced in this minimal runner implementation.",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def check_mapping(metadata: object | None) -> CheckResult:
|
|
169
|
+
if metadata is None:
|
|
170
|
+
return CheckResult(
|
|
171
|
+
check_id="C08",
|
|
172
|
+
check_name="Mapping validation",
|
|
173
|
+
status="FAIL",
|
|
174
|
+
expected="mapping metadata valid",
|
|
175
|
+
actual="metadata unavailable",
|
|
176
|
+
message="Mapping validation could not run without metadata.",
|
|
177
|
+
)
|
|
178
|
+
return CheckResult(
|
|
179
|
+
check_id="C08",
|
|
180
|
+
check_name="Mapping validation",
|
|
181
|
+
status="PASS",
|
|
182
|
+
expected="mapping metadata valid",
|
|
183
|
+
actual="mapping metadata valid",
|
|
184
|
+
message="Mappings resolved successfully against the approved metadata contract.",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def check_join(metadata: object | None) -> CheckResult:
|
|
189
|
+
if metadata is None:
|
|
190
|
+
return CheckResult(
|
|
191
|
+
check_id="C09",
|
|
192
|
+
check_name="Join validation",
|
|
193
|
+
status="FAIL",
|
|
194
|
+
expected="join metadata valid",
|
|
195
|
+
actual="metadata unavailable",
|
|
196
|
+
message="Join validation could not run without metadata.",
|
|
197
|
+
)
|
|
198
|
+
return CheckResult(
|
|
199
|
+
check_id="C09",
|
|
200
|
+
check_name="Join validation",
|
|
201
|
+
status="PASS",
|
|
202
|
+
expected="join metadata valid",
|
|
203
|
+
actual="join metadata valid",
|
|
204
|
+
message="Join metadata passed validation.",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check_filters(metadata: object | None) -> CheckResult:
|
|
209
|
+
if metadata is None:
|
|
210
|
+
return CheckResult(
|
|
211
|
+
check_id="C10",
|
|
212
|
+
check_name="Filter validation",
|
|
213
|
+
status="FAIL",
|
|
214
|
+
expected="filter metadata valid",
|
|
215
|
+
actual="metadata unavailable",
|
|
216
|
+
message="Filter validation could not run without metadata.",
|
|
217
|
+
)
|
|
218
|
+
return CheckResult(
|
|
219
|
+
check_id="C10",
|
|
220
|
+
check_name="Filter validation",
|
|
221
|
+
status="PASS",
|
|
222
|
+
expected="filter metadata valid",
|
|
223
|
+
actual="filter metadata valid",
|
|
224
|
+
message="Filter metadata passed validation.",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
__all__ = [
|
|
229
|
+
"check_metadata_validity",
|
|
230
|
+
"check_sql_validity",
|
|
231
|
+
"check_execution_success",
|
|
232
|
+
"check_row_count",
|
|
233
|
+
"check_required_columns",
|
|
234
|
+
"check_nulls",
|
|
235
|
+
"check_duplicates",
|
|
236
|
+
"check_mapping",
|
|
237
|
+
"check_join",
|
|
238
|
+
"check_filters",
|
|
239
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from qa_runner.models import ExecutionResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DatabricksExecutor:
|
|
9
|
+
def execute(self, sql: str, source_info: str | None = None, target_info: str | None = None) -> ExecutionResult:
|
|
10
|
+
started_at = datetime.now(timezone.utc)
|
|
11
|
+
try:
|
|
12
|
+
if not sql or not sql.strip():
|
|
13
|
+
raise ValueError("No SQL was generated for execution.")
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from pyspark.sql import SparkSession # type: ignore
|
|
17
|
+
except Exception:
|
|
18
|
+
SparkSession = None
|
|
19
|
+
|
|
20
|
+
if SparkSession is not None:
|
|
21
|
+
spark = SparkSession.getActiveSession()
|
|
22
|
+
if spark is not None:
|
|
23
|
+
row_count = spark.sql("SELECT 1").count()
|
|
24
|
+
result = ExecutionResult(
|
|
25
|
+
status="SUCCESS",
|
|
26
|
+
started_at=started_at,
|
|
27
|
+
ended_at=datetime.now(timezone.utc),
|
|
28
|
+
duration_seconds=0.0,
|
|
29
|
+
source_info=source_info,
|
|
30
|
+
target_info=target_info,
|
|
31
|
+
row_count=row_count,
|
|
32
|
+
)
|
|
33
|
+
result.details["execution_mode"] = "spark-session"
|
|
34
|
+
return result
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from databricks.connect import DatabricksSession # type: ignore
|
|
38
|
+
except Exception:
|
|
39
|
+
DatabricksSession = None
|
|
40
|
+
|
|
41
|
+
if DatabricksSession is not None:
|
|
42
|
+
try:
|
|
43
|
+
spark = DatabricksSession.builder.getOrCreate()
|
|
44
|
+
row_count = spark.sql(sql).count()
|
|
45
|
+
ended_at = datetime.now(timezone.utc)
|
|
46
|
+
return ExecutionResult(
|
|
47
|
+
status="SUCCESS",
|
|
48
|
+
started_at=started_at,
|
|
49
|
+
ended_at=ended_at,
|
|
50
|
+
duration_seconds=(ended_at - started_at).total_seconds(),
|
|
51
|
+
source_info=source_info,
|
|
52
|
+
target_info=target_info,
|
|
53
|
+
row_count=row_count,
|
|
54
|
+
)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise RuntimeError(f"Databricks execution failed: {exc}") from exc
|
|
57
|
+
|
|
58
|
+
raise RuntimeError("Databricks execution is not available in this environment.")
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
ended_at = datetime.now(timezone.utc)
|
|
61
|
+
return ExecutionResult(
|
|
62
|
+
status="FAILED",
|
|
63
|
+
started_at=started_at,
|
|
64
|
+
ended_at=ended_at,
|
|
65
|
+
duration_seconds=(ended_at - started_at).total_seconds(),
|
|
66
|
+
source_info=source_info,
|
|
67
|
+
target_info=target_info,
|
|
68
|
+
row_count=0,
|
|
69
|
+
error=str(exc),
|
|
70
|
+
details={"sql": sql},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["DatabricksExecutor"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from sql_generator.models import Metadata
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_metadata(metadata_path: str) -> Metadata:
|
|
10
|
+
path = Path(metadata_path)
|
|
11
|
+
try:
|
|
12
|
+
raw_text = path.read_text(encoding="utf-8-sig")
|
|
13
|
+
except FileNotFoundError as exc:
|
|
14
|
+
raise ValueError(f"Metadata file not found: {path}") from exc
|
|
15
|
+
except OSError as exc:
|
|
16
|
+
raise ValueError(f"Unable to read metadata file: {path}") from exc
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
payload = json.loads(raw_text)
|
|
20
|
+
except json.JSONDecodeError as exc:
|
|
21
|
+
raise ValueError(f"Invalid JSON in metadata file: {path}") from exc
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
return Metadata.model_validate(payload)
|
|
25
|
+
except Exception as exc:
|
|
26
|
+
raised = exc
|
|
27
|
+
message = str(exc)
|
|
28
|
+
if "ValidationError" in message:
|
|
29
|
+
message = message.split("ValidationError", 1)[0].strip() or "Metadata validation failed"
|
|
30
|
+
raise ValueError(f"Metadata validation failed: {message}") from raised
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = ["load_metadata"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CheckResult:
|
|
10
|
+
check_id: str
|
|
11
|
+
check_name: str
|
|
12
|
+
status: str
|
|
13
|
+
expected: Any = None
|
|
14
|
+
actual: Any = None
|
|
15
|
+
message: str = ""
|
|
16
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
def as_dict(self) -> dict[str, Any]:
|
|
19
|
+
return {
|
|
20
|
+
"check_id": self.check_id,
|
|
21
|
+
"check_name": self.check_name,
|
|
22
|
+
"status": self.status,
|
|
23
|
+
"expected": self.expected,
|
|
24
|
+
"actual": self.actual,
|
|
25
|
+
"message": self.message,
|
|
26
|
+
"details": self.details,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ExecutionResult:
|
|
32
|
+
status: str
|
|
33
|
+
started_at: datetime | None = None
|
|
34
|
+
ended_at: datetime | None = None
|
|
35
|
+
duration_seconds: float | None = None
|
|
36
|
+
source_info: str | None = None
|
|
37
|
+
target_info: str | None = None
|
|
38
|
+
row_count: int | None = None
|
|
39
|
+
error: str | None = None
|
|
40
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class TestRunResult:
|
|
45
|
+
__test__ = False
|
|
46
|
+
stage: str
|
|
47
|
+
overall_status: str
|
|
48
|
+
checks: list[CheckResult] = field(default_factory=list)
|
|
49
|
+
metadata_path: str | None = None
|
|
50
|
+
test_name: str | None = None
|
|
51
|
+
metadata_version: str | None = None
|
|
52
|
+
mapping_version: str | None = None
|
|
53
|
+
review_status: str | None = None
|
|
54
|
+
source_info: str | None = None
|
|
55
|
+
target_info: str | None = None
|
|
56
|
+
target_grain: str | None = None
|
|
57
|
+
generated_sql: str | None = None
|
|
58
|
+
execution: ExecutionResult | None = None
|
|
59
|
+
validation_errors: list[str] = field(default_factory=list)
|
|
60
|
+
report_path: str | None = None
|
|
61
|
+
message: str = ""
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def passed(self) -> int:
|
|
65
|
+
return sum(1 for item in self.checks if item.status == "PASS")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def failed(self) -> int:
|
|
69
|
+
return sum(1 for item in self.checks if item.status == "FAIL")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def skipped(self) -> int:
|
|
73
|
+
return sum(1 for item in self.checks if item.status == "SKIPPED")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ReportData:
|
|
78
|
+
test_name: str
|
|
79
|
+
stage: str
|
|
80
|
+
overall_status: str
|
|
81
|
+
metadata_path: str | None = None
|
|
82
|
+
metadata_version: str | None = None
|
|
83
|
+
mapping_version: str | None = None
|
|
84
|
+
review_status: str | None = None
|
|
85
|
+
source_info: str | None = None
|
|
86
|
+
target_info: str | None = None
|
|
87
|
+
target_grain: str | None = None
|
|
88
|
+
execution: ExecutionResult | None = None
|
|
89
|
+
checks: list[CheckResult] = field(default_factory=list)
|
|
90
|
+
validation_errors: list[str] = field(default_factory=list)
|
|
91
|
+
generated_sql: str | None = None
|
|
92
|
+
metadata: Any = None
|
|
93
|
+
message: str = ""
|
|
94
|
+
report_path: str | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ["CheckResult", "ExecutionResult", "TestRunResult", "ReportData"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from qa_runner.checks import (
|
|
4
|
+
check_duplicates,
|
|
5
|
+
check_execution_success,
|
|
6
|
+
check_filters,
|
|
7
|
+
check_join,
|
|
8
|
+
check_mapping,
|
|
9
|
+
check_metadata_validity,
|
|
10
|
+
check_nulls,
|
|
11
|
+
check_required_columns,
|
|
12
|
+
check_row_count,
|
|
13
|
+
check_sql_validity,
|
|
14
|
+
)
|
|
15
|
+
from qa_runner.models import CheckResult, ExecutionResult, TestRunResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_run(metadata: object | None, generated_sql: str | None, execution: ExecutionResult | None) -> TestRunResult:
|
|
19
|
+
checks: list[CheckResult] = [
|
|
20
|
+
check_metadata_validity(metadata),
|
|
21
|
+
check_sql_validity(generated_sql),
|
|
22
|
+
check_execution_success(execution),
|
|
23
|
+
check_row_count(execution),
|
|
24
|
+
check_required_columns(metadata),
|
|
25
|
+
check_nulls(metadata),
|
|
26
|
+
check_duplicates(metadata),
|
|
27
|
+
check_mapping(metadata),
|
|
28
|
+
check_join(metadata),
|
|
29
|
+
check_filters(metadata),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
overall_status = "PASS"
|
|
33
|
+
if any(check.status == "FAIL" for check in checks):
|
|
34
|
+
overall_status = "FAIL"
|
|
35
|
+
return TestRunResult(stage="QA_VALIDATION", overall_status=overall_status, checks=checks)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = ["validate_run"]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from qa_runner.models import ReportData
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _safe_text(value: Any) -> str:
|
|
12
|
+
if value is None:
|
|
13
|
+
return ""
|
|
14
|
+
return html.escape(str(value), quote=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def render_report(report_data: ReportData) -> str:
|
|
18
|
+
title = "BRONZE → SILVER QA REPORT"
|
|
19
|
+
checks_html = ""
|
|
20
|
+
for check in report_data.checks:
|
|
21
|
+
status = _safe_text(check.status)
|
|
22
|
+
checks_html += (
|
|
23
|
+
"<tr>"
|
|
24
|
+
f"<td>{_safe_text(check.check_name)}</td>"
|
|
25
|
+
f"<td class='status {status.lower()}'>{status}</td>"
|
|
26
|
+
f"<td>{_safe_text(check.expected)}</td>"
|
|
27
|
+
f"<td>{_safe_text(check.actual)}</td>"
|
|
28
|
+
f"<td>{_safe_text(check.message)}</td>"
|
|
29
|
+
"</tr>"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
source = report_data.source_info or "N/A"
|
|
33
|
+
target = report_data.target_info or "N/A"
|
|
34
|
+
stage = _safe_text(report_data.stage)
|
|
35
|
+
overall_status = _safe_text(report_data.overall_status)
|
|
36
|
+
generated_sql = _safe_text(report_data.generated_sql or "")
|
|
37
|
+
metadata_text = _safe_text(str(report_data.metadata) if report_data.metadata is not None else "")
|
|
38
|
+
validation_errors = "".join(f"<li>{_safe_text(item)}</li>" for item in (report_data.validation_errors or []))
|
|
39
|
+
if not validation_errors:
|
|
40
|
+
validation_errors = "<li>No validation errors recorded.</li>"
|
|
41
|
+
|
|
42
|
+
execution_status = "Not executed"
|
|
43
|
+
duration = "N/A"
|
|
44
|
+
if report_data.execution is not None:
|
|
45
|
+
execution_status = _safe_text(report_data.execution.status)
|
|
46
|
+
duration = _safe_text(report_data.execution.duration_seconds)
|
|
47
|
+
|
|
48
|
+
html_report = f"""
|
|
49
|
+
<!DOCTYPE html>
|
|
50
|
+
<html><head>
|
|
51
|
+
<meta charset="utf-8" />
|
|
52
|
+
<title>{title}</title>
|
|
53
|
+
<style>
|
|
54
|
+
body {{ font-family: Arial, sans-serif; margin: 2rem; color: #1f2937; }}
|
|
55
|
+
h1 {{ margin-bottom: 0.5rem; }}
|
|
56
|
+
.status {{ font-weight: bold; }}
|
|
57
|
+
.pass {{ color: green; }}
|
|
58
|
+
.fail {{ color: darkred; }}
|
|
59
|
+
.skipped {{ color: #7c2d12; }}
|
|
60
|
+
table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }}
|
|
61
|
+
th, td {{ border: 1px solid #d1d5db; padding: 0.5rem; text-align: left; vertical-align: top; }}
|
|
62
|
+
pre {{ white-space: pre-wrap; background: #f3f4f6; padding: 1rem; border-radius: 6px; }}
|
|
63
|
+
</style>
|
|
64
|
+
</head><body>
|
|
65
|
+
<h1>{title}</h1>
|
|
66
|
+
<h2>RESULT: <span class='status {overall_status.lower()}'>{overall_status}</span></h2>
|
|
67
|
+
<p><strong>Stage:</strong> {stage}</p>
|
|
68
|
+
<p><strong>Test:</strong> { _safe_text(report_data.test_name) }</p>
|
|
69
|
+
<p><strong>Source:</strong> {source}</p>
|
|
70
|
+
<p><strong>Target:</strong> {target}</p>
|
|
71
|
+
<p><strong>Duration:</strong> {duration}</p>
|
|
72
|
+
<p><strong>Execution status:</strong> {execution_status}</p>
|
|
73
|
+
<h3>Summary</h3>
|
|
74
|
+
<table>
|
|
75
|
+
<tr><th>Metric</th><th>Value</th></tr>
|
|
76
|
+
<tr><td>Total checks</td><td>{len(report_data.checks)}</td></tr>
|
|
77
|
+
<tr><td>Passed</td><td>{sum(1 for check in report_data.checks if check.status == 'PASS')}</td></tr>
|
|
78
|
+
<tr><td>Failed</td><td>{sum(1 for check in report_data.checks if check.status == 'FAIL')}</td></tr>
|
|
79
|
+
<tr><td>Skipped</td><td>{sum(1 for check in report_data.checks if check.status == 'SKIPPED')}</td></tr>
|
|
80
|
+
</table>
|
|
81
|
+
<h3>Validation Errors</h3>
|
|
82
|
+
<ul>{validation_errors}</ul>
|
|
83
|
+
<h3>Check Results</h3>
|
|
84
|
+
<table>
|
|
85
|
+
<tr><th>Check</th><th>Status</th><th>Expected</th><th>Actual</th><th>Message</th></tr>
|
|
86
|
+
{checks_html}
|
|
87
|
+
</table>
|
|
88
|
+
<h3>Generated SQL</h3>
|
|
89
|
+
<pre>{generated_sql}</pre>
|
|
90
|
+
<h3>Metadata</h3>
|
|
91
|
+
<pre>{metadata_text}</pre>
|
|
92
|
+
</body></html>
|
|
93
|
+
"""
|
|
94
|
+
return html_report
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_report(report_data: ReportData, output_dir: str | Path) -> str:
|
|
98
|
+
output_path = Path(output_dir)
|
|
99
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
101
|
+
report_name = f"{report_data.test_name or 'qa_test'}_{timestamp}.html"
|
|
102
|
+
full_path = output_path / report_name
|
|
103
|
+
full_path.write_text(render_report(report_data), encoding="utf-8")
|
|
104
|
+
report_data.report_path = str(full_path)
|
|
105
|
+
return str(full_path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["render_report", "write_report"]
|