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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ from .runner import run_test
2
+
3
+ __all__ = ["run_test"]
4
+
5
+ # Backward-compatible alias for the preferred public CLI name.
6
+ setu = run_test
@@ -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"]