trustline 0.1.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.
- trustline/__init__.py +18 -0
- trustline/cli/__init__.py +1 -0
- trustline/cli/audit.py +267 -0
- trustline/cli/main.py +37 -0
- trustline/cli/validate.py +115 -0
- trustline/compiler/__init__.py +1 -0
- trustline/compiler/audit_profile.py +79 -0
- trustline/compiler/cohort.py +59 -0
- trustline/compiler/funnel.py +100 -0
- trustline/compiler/paths.py +15 -0
- trustline/compiler/templates.py +66 -0
- trustline/config.py +135 -0
- trustline/contracts/__init__.py +1 -0
- trustline/contracts/audit_profile.py +98 -0
- trustline/contracts/loader.py +91 -0
- trustline/contracts/models.py +181 -0
- trustline/contracts/schemas.py +15 -0
- trustline/contracts/validator.py +208 -0
- trustline/exceptions.py +17 -0
- trustline/executors/__init__.py +5 -0
- trustline/executors/base.py +127 -0
- trustline/executors/duckdb.py +50 -0
- trustline/executors/snowflake.py +109 -0
- trustline/integrations/__init__.py +9 -0
- trustline/integrations/slack.py +84 -0
- trustline/reporters/__init__.py +8 -0
- trustline/reporters/brief.py +45 -0
- trustline/reporters/json_report.py +74 -0
- trustline/reporters/markdown.py +74 -0
- trustline/reporters/redact.py +42 -0
- trustline/reporters/rich_console.py +96 -0
- trustline/reporters/scoring.py +50 -0
- trustline/schemas/audit_profile.schema.json +52 -0
- trustline/schemas/cohort.schema.json +96 -0
- trustline/schemas/common.schema.json +47 -0
- trustline/schemas/funnel.schema.json +131 -0
- trustline/scorecard/__init__.py +6 -0
- trustline/scorecard/_common.py +41 -0
- trustline/scorecard/orchestrator.py +92 -0
- trustline/scorecard/phase1_pipeline.py +34 -0
- trustline/scorecard/phase2_funnel.py +33 -0
- trustline/scorecard/phase3_semantics.py +34 -0
- trustline/scorecard/phase4_training.py +33 -0
- trustline/scorecard/phase5_brief.py +110 -0
- trustline/scorecard/types.py +40 -0
- trustline/templates/sql/cohort_positive_rate.sql.j2 +2 -0
- trustline/templates/sql/cohort_source_parity.sql.j2 +5 -0
- trustline/templates/sql/crm_coverage_gap.sql.j2 +9 -0
- trustline/templates/sql/funnel_retention.sql.j2 +12 -0
- trustline/templates/sql/funnel_stage_count.sql.j2 +4 -0
- trustline/templates/sql/funnel_stage_join.sql.j2 +6 -0
- trustline/templates/sql/score_distribution.sql.j2 +8 -0
- trustline/templates/sql/source_swap_volume.sql.j2 +30 -0
- trustline-0.1.0.dist-info/METADATA +98 -0
- trustline-0.1.0.dist-info/RECORD +58 -0
- trustline-0.1.0.dist-info/WHEEL +4 -0
- trustline-0.1.0.dist-info/entry_points.txt +2 -0
- trustline-0.1.0.dist-info/licenses/LICENSE +202 -0
trustline/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Trustline — open-source trust layer for data products.
|
|
2
|
+
|
|
3
|
+
Public API (v0.2+):
|
|
4
|
+
validate: Validate contract YAML against JSON Schema
|
|
5
|
+
audit: Run five-phase trust scorecard
|
|
6
|
+
|
|
7
|
+
Current exports:
|
|
8
|
+
__version__: Package version string
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = version("trustline")
|
|
15
|
+
except PackageNotFoundError:
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package for Trustline commands."""
|
trustline/cli/audit.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""trustline audit — five-phase trust scorecard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Literal
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from trustline.compiler.audit_profile import compile_audit_profile_checks
|
|
13
|
+
from trustline.compiler.cohort import compile_cohort_checks
|
|
14
|
+
from trustline.compiler.funnel import compile_funnel_checks
|
|
15
|
+
from trustline.config import (
|
|
16
|
+
Profile,
|
|
17
|
+
load_profile,
|
|
18
|
+
resolve_duckdb_path,
|
|
19
|
+
resolve_profiles_path,
|
|
20
|
+
)
|
|
21
|
+
from trustline.contracts.audit_profile import AuditProfile, load_audit_profile
|
|
22
|
+
from trustline.contracts.loader import load_contracts_dir
|
|
23
|
+
from trustline.contracts.models import CohortManifest, FunnelContract
|
|
24
|
+
from trustline.exceptions import AuditError, ExecutorError, TrustlineError, ValidationError
|
|
25
|
+
from trustline.executors.base import CompiledCheck, Executor
|
|
26
|
+
from trustline.executors.duckdb import DuckDBExecutor
|
|
27
|
+
from trustline.reporters.brief import render_brief
|
|
28
|
+
from trustline.reporters.json_report import render_scorecard_json
|
|
29
|
+
from trustline.reporters.markdown import render_scorecard
|
|
30
|
+
from trustline.reporters.rich_console import render_scorecard_console
|
|
31
|
+
from trustline.scorecard._common import dialect_for_profile
|
|
32
|
+
from trustline.scorecard.orchestrator import run_full_audit
|
|
33
|
+
from trustline.scorecard.types import ScorecardResult
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
app = typer.Typer(help="Run five-phase trust scorecard audit.")
|
|
38
|
+
OutputFormat = Literal["text", "json", "both"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _default_audit_profile_path(contracts_dir: Path) -> Path:
|
|
42
|
+
return contracts_dir.parent / "audit_profile.yaml"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_audit_profile(path: Path) -> AuditProfile | None:
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
return None
|
|
48
|
+
return load_audit_profile(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _compile_checks(
|
|
52
|
+
contracts: list[FunnelContract | CohortManifest],
|
|
53
|
+
audit_profile: AuditProfile | None,
|
|
54
|
+
profile: Profile,
|
|
55
|
+
) -> list[CompiledCheck]:
|
|
56
|
+
dialect = dialect_for_profile(profile.target)
|
|
57
|
+
checks: list[CompiledCheck] = []
|
|
58
|
+
if audit_profile is not None:
|
|
59
|
+
checks.extend(compile_audit_profile_checks(audit_profile, profile, dialect))
|
|
60
|
+
for contract in contracts:
|
|
61
|
+
if isinstance(contract, FunnelContract):
|
|
62
|
+
checks.extend(compile_funnel_checks(contract, profile, dialect))
|
|
63
|
+
elif isinstance(contract, CohortManifest):
|
|
64
|
+
checks.extend(compile_cohort_checks(contract, profile, dialect))
|
|
65
|
+
return checks
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _build_executor(profile: Profile, profiles_path: Path) -> Executor:
|
|
69
|
+
if profile.target == "duckdb":
|
|
70
|
+
database = resolve_duckdb_path(profile, profiles_path)
|
|
71
|
+
if not database.is_file():
|
|
72
|
+
msg = f"DuckDB database not found: {database}"
|
|
73
|
+
raise TrustlineError(msg)
|
|
74
|
+
return DuckDBExecutor(database)
|
|
75
|
+
if profile.target == "snowflake":
|
|
76
|
+
from trustline.executors.snowflake import SnowflakeExecutor
|
|
77
|
+
|
|
78
|
+
return SnowflakeExecutor.from_env(profile)
|
|
79
|
+
msg = f"unsupported warehouse target: {profile.target!r}"
|
|
80
|
+
raise TrustlineError(msg)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _print_text_summary(
|
|
84
|
+
result: ScorecardResult,
|
|
85
|
+
*,
|
|
86
|
+
title: str,
|
|
87
|
+
no_color: bool = False,
|
|
88
|
+
) -> None:
|
|
89
|
+
render_scorecard_console(result, title=title, no_color=no_color)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _write_reports(result: ScorecardResult, output_dir: Path, *, title: str) -> None:
|
|
93
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
(output_dir / "scorecard.md").write_text(
|
|
95
|
+
render_scorecard(result, title=title),
|
|
96
|
+
encoding="utf-8",
|
|
97
|
+
)
|
|
98
|
+
(output_dir / "scorecard.json").write_text(
|
|
99
|
+
json.dumps(render_scorecard_json(result), indent=2) + "\n",
|
|
100
|
+
encoding="utf-8",
|
|
101
|
+
)
|
|
102
|
+
(output_dir / "brief.md").write_text(render_brief(result), encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _exit_code_for_verdict(verdict: str) -> int:
|
|
106
|
+
return 1 if verdict == "fail" else 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _maybe_notify_slack(
|
|
110
|
+
notify: str | None,
|
|
111
|
+
slack_webhook: str | None,
|
|
112
|
+
result: ScorecardResult,
|
|
113
|
+
*,
|
|
114
|
+
title: str,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Send Slack notification when configured and the audit failed."""
|
|
117
|
+
if notify is None:
|
|
118
|
+
return
|
|
119
|
+
if notify != "slack":
|
|
120
|
+
msg = f"unsupported notification channel: {notify!r}"
|
|
121
|
+
raise TrustlineError(msg)
|
|
122
|
+
if result.verdict != "fail":
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
from trustline.integrations.slack import notify_audit_failure, resolve_webhook_url
|
|
126
|
+
|
|
127
|
+
webhook_url = resolve_webhook_url(slack_webhook)
|
|
128
|
+
notify_audit_failure(webhook_url, result, title=title)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.callback(invoke_without_command=True)
|
|
132
|
+
def audit( # noqa: PLR0913
|
|
133
|
+
contracts: str = typer.Option(
|
|
134
|
+
"./contracts",
|
|
135
|
+
"--contracts",
|
|
136
|
+
"-c",
|
|
137
|
+
help="Directory containing contract YAML files.",
|
|
138
|
+
),
|
|
139
|
+
target: str = typer.Option(
|
|
140
|
+
"duckdb",
|
|
141
|
+
"--target",
|
|
142
|
+
"-t",
|
|
143
|
+
help="Warehouse target: duckdb or snowflake.",
|
|
144
|
+
),
|
|
145
|
+
profile_name: str = typer.Option(
|
|
146
|
+
"default",
|
|
147
|
+
"--profile",
|
|
148
|
+
"-p",
|
|
149
|
+
help="Profile name in profiles.yml.",
|
|
150
|
+
),
|
|
151
|
+
audit_profile_path: str | None = typer.Option(
|
|
152
|
+
None,
|
|
153
|
+
"--audit-profile",
|
|
154
|
+
help="Path to audit_profile.yaml (default: sibling of contracts directory).",
|
|
155
|
+
),
|
|
156
|
+
profiles_path: str | None = typer.Option(
|
|
157
|
+
None,
|
|
158
|
+
"--profiles",
|
|
159
|
+
help="Path to profiles.yml (default: ./profiles.yml or ACME example).",
|
|
160
|
+
),
|
|
161
|
+
output_dir: str = typer.Option(
|
|
162
|
+
".",
|
|
163
|
+
"--output-dir",
|
|
164
|
+
help="Directory for scorecard.md and scorecard.json.",
|
|
165
|
+
),
|
|
166
|
+
output_format: Annotated[
|
|
167
|
+
OutputFormat,
|
|
168
|
+
typer.Option("--output", "-o", help="Output format: text, json, or both."),
|
|
169
|
+
] = "text",
|
|
170
|
+
dry_run: bool = typer.Option(
|
|
171
|
+
False,
|
|
172
|
+
"--dry-run",
|
|
173
|
+
help="Compile checks only; do not execute SQL.",
|
|
174
|
+
),
|
|
175
|
+
notify: str | None = typer.Option(
|
|
176
|
+
None,
|
|
177
|
+
"--notify",
|
|
178
|
+
help="Notification channel on failure (slack).",
|
|
179
|
+
),
|
|
180
|
+
slack_webhook: str | None = typer.Option(
|
|
181
|
+
None,
|
|
182
|
+
"--slack-webhook",
|
|
183
|
+
envvar="SLACK_WEBHOOK_URL",
|
|
184
|
+
help="Slack incoming webhook URL.",
|
|
185
|
+
),
|
|
186
|
+
no_color: bool = typer.Option(
|
|
187
|
+
False,
|
|
188
|
+
"--no-color",
|
|
189
|
+
help="Disable ANSI colors in terminal output.",
|
|
190
|
+
),
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Run the five-phase trust scorecard against contract YAML."""
|
|
193
|
+
try:
|
|
194
|
+
contracts_dir = Path(contracts)
|
|
195
|
+
if not contracts_dir.is_dir():
|
|
196
|
+
typer.echo(f"ERROR: contracts directory not found: {contracts_dir}", err=True)
|
|
197
|
+
raise typer.Exit(code=2)
|
|
198
|
+
|
|
199
|
+
resolved_profiles = resolve_profiles_path(
|
|
200
|
+
Path(profiles_path) if profiles_path is not None else None
|
|
201
|
+
)
|
|
202
|
+
if not resolved_profiles.is_file():
|
|
203
|
+
typer.echo(f"ERROR: profiles file not found: {resolved_profiles}", err=True)
|
|
204
|
+
raise typer.Exit(code=2)
|
|
205
|
+
|
|
206
|
+
profile = load_profile(profile_name, resolved_profiles)
|
|
207
|
+
if profile.target != target:
|
|
208
|
+
typer.echo(
|
|
209
|
+
f"ERROR: profile {profile_name!r} target {profile.target!r} "
|
|
210
|
+
f"does not match --target {target!r}",
|
|
211
|
+
err=True,
|
|
212
|
+
)
|
|
213
|
+
raise typer.Exit(code=2)
|
|
214
|
+
|
|
215
|
+
documents = load_contracts_dir(contracts_dir)
|
|
216
|
+
if not documents:
|
|
217
|
+
typer.echo("ERROR: no contract files found.", err=True)
|
|
218
|
+
raise typer.Exit(code=2)
|
|
219
|
+
|
|
220
|
+
audit_path = (
|
|
221
|
+
Path(audit_profile_path)
|
|
222
|
+
if audit_profile_path is not None
|
|
223
|
+
else _default_audit_profile_path(contracts_dir)
|
|
224
|
+
)
|
|
225
|
+
audit_profile = _load_audit_profile(audit_path)
|
|
226
|
+
|
|
227
|
+
if dry_run:
|
|
228
|
+
compiled = _compile_checks(documents, audit_profile, profile)
|
|
229
|
+
typer.echo(f"Compiled {len(compiled)} checks (dry run).")
|
|
230
|
+
raise typer.Exit(code=0)
|
|
231
|
+
|
|
232
|
+
executor = _build_executor(profile, resolved_profiles)
|
|
233
|
+
try:
|
|
234
|
+
result = run_full_audit(documents, audit_profile, executor, profile)
|
|
235
|
+
finally:
|
|
236
|
+
if hasattr(executor, "close"):
|
|
237
|
+
executor.close()
|
|
238
|
+
|
|
239
|
+
title = contracts_dir.parent.name.replace("_", " ").title()
|
|
240
|
+
reports_dir = Path(output_dir)
|
|
241
|
+
_write_reports(result, reports_dir, title=title)
|
|
242
|
+
|
|
243
|
+
if output_format in {"text", "both"}:
|
|
244
|
+
_print_text_summary(result, title=title, no_color=no_color)
|
|
245
|
+
typer.echo(f"\nReports written to {reports_dir.resolve()}/")
|
|
246
|
+
if output_format in {"json", "both"}:
|
|
247
|
+
typer.echo(json.dumps(render_scorecard_json(result), indent=2))
|
|
248
|
+
|
|
249
|
+
_maybe_notify_slack(notify, slack_webhook, result, title=title)
|
|
250
|
+
|
|
251
|
+
raise typer.Exit(code=_exit_code_for_verdict(result.verdict))
|
|
252
|
+
except typer.Exit:
|
|
253
|
+
raise
|
|
254
|
+
except FileNotFoundError as exc:
|
|
255
|
+
typer.echo(f"ERROR: {exc}", err=True)
|
|
256
|
+
raise typer.Exit(code=2) from exc
|
|
257
|
+
except (ValidationError, TrustlineError, AuditError) as exc:
|
|
258
|
+
typer.echo(f"ERROR: {exc}", err=True)
|
|
259
|
+
raise typer.Exit(code=2) from exc
|
|
260
|
+
except ExecutorError as exc:
|
|
261
|
+
logger.exception("audit executor failure")
|
|
262
|
+
typer.echo(f"ERROR: {exc}", err=True)
|
|
263
|
+
raise typer.Exit(code=3) from exc
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
logger.exception("unexpected audit failure")
|
|
266
|
+
typer.echo(f"ERROR: internal error: {exc}", err=True)
|
|
267
|
+
raise typer.Exit(code=3) from exc
|
trustline/cli/main.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Trustline CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from trustline.cli import audit, validate
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="trustline",
|
|
11
|
+
help="Open-source trust layer for data products.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _version_callback(value: bool) -> None:
|
|
17
|
+
if value:
|
|
18
|
+
typer.echo(version("trustline"))
|
|
19
|
+
raise typer.Exit()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.callback()
|
|
23
|
+
def main(
|
|
24
|
+
version_flag: bool = typer.Option(
|
|
25
|
+
False,
|
|
26
|
+
"--version",
|
|
27
|
+
"-V",
|
|
28
|
+
help="Show version and exit.",
|
|
29
|
+
callback=_version_callback,
|
|
30
|
+
is_eager=True,
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Trustline — machine-checkable contracts across ETL, ML, and delivery seams."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
app.add_typer(validate.app, name="validate")
|
|
37
|
+
app.add_typer(audit.app, name="audit")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""trustline validate — contract YAML validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from trustline.contracts.validator import (
|
|
11
|
+
ValidationSummary,
|
|
12
|
+
validate_contract_file,
|
|
13
|
+
validate_contracts_dir,
|
|
14
|
+
)
|
|
15
|
+
from trustline.exceptions import TrustlineError, ValidationError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="Validate contract YAML against JSON Schema.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_summary(summary: ValidationSummary) -> None:
|
|
23
|
+
"""Print per-file validation results."""
|
|
24
|
+
typer.echo(f"Validating {summary.total} contracts...\n")
|
|
25
|
+
for result in summary.results:
|
|
26
|
+
status = "PASS" if result.passed else "FAIL"
|
|
27
|
+
kind = result.kind or "Unknown"
|
|
28
|
+
typer.echo(f" {result.path.name:<40} {kind:<20} {status}")
|
|
29
|
+
if not result.passed:
|
|
30
|
+
for error in result.errors:
|
|
31
|
+
typer.echo(error, err=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _summary_from_file(path: Path) -> ValidationSummary:
|
|
35
|
+
"""Build a validation summary for a single file."""
|
|
36
|
+
result = validate_contract_file(path)
|
|
37
|
+
passed = 1 if result.passed else 0
|
|
38
|
+
return ValidationSummary(
|
|
39
|
+
total=1,
|
|
40
|
+
passed=passed,
|
|
41
|
+
failed=1 - passed,
|
|
42
|
+
results=(result,),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _exit_code_for_summary(summary: ValidationSummary) -> int:
|
|
47
|
+
"""Map validation summary to CLI exit code."""
|
|
48
|
+
if summary.failed:
|
|
49
|
+
return 1
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.callback(invoke_without_command=True)
|
|
54
|
+
def validate(
|
|
55
|
+
contracts: str = typer.Option(
|
|
56
|
+
"./contracts",
|
|
57
|
+
"--contracts",
|
|
58
|
+
"-c",
|
|
59
|
+
help="Directory containing contract YAML files.",
|
|
60
|
+
),
|
|
61
|
+
file: str | None = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--file",
|
|
64
|
+
"-f",
|
|
65
|
+
help="Validate a single contract file instead of a directory.",
|
|
66
|
+
),
|
|
67
|
+
strict: bool = typer.Option(
|
|
68
|
+
False,
|
|
69
|
+
"--strict",
|
|
70
|
+
help="Treat warnings as errors (reserved for future use).",
|
|
71
|
+
),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Validate contract YAML files against JSON Schema."""
|
|
74
|
+
del strict # reserved for v0.1; schema errors already fail validation
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
if file is not None:
|
|
78
|
+
path = Path(file)
|
|
79
|
+
if not path.is_file():
|
|
80
|
+
typer.echo(f"ERROR: contract file not found: {path}", err=True)
|
|
81
|
+
raise typer.Exit(code=2)
|
|
82
|
+
summary = _summary_from_file(path)
|
|
83
|
+
else:
|
|
84
|
+
directory = Path(contracts)
|
|
85
|
+
if not directory.is_dir():
|
|
86
|
+
typer.echo(f"ERROR: contracts directory not found: {directory}", err=True)
|
|
87
|
+
raise typer.Exit(code=2)
|
|
88
|
+
summary = validate_contracts_dir(directory)
|
|
89
|
+
|
|
90
|
+
_print_summary(summary)
|
|
91
|
+
if summary.total == 0:
|
|
92
|
+
typer.echo("\nNo contract files found.")
|
|
93
|
+
raise typer.Exit(code=2)
|
|
94
|
+
|
|
95
|
+
if summary.failed:
|
|
96
|
+
typer.echo("\nValidation failed.")
|
|
97
|
+
raise typer.Exit(code=_exit_code_for_summary(summary))
|
|
98
|
+
|
|
99
|
+
typer.echo("\nAll contracts valid.")
|
|
100
|
+
except typer.Exit:
|
|
101
|
+
raise
|
|
102
|
+
except FileNotFoundError as exc:
|
|
103
|
+
typer.echo(f"ERROR: {exc}", err=True)
|
|
104
|
+
raise typer.Exit(code=2) from exc
|
|
105
|
+
except ValidationError as exc:
|
|
106
|
+
typer.echo(f"ERROR: {exc}", err=True)
|
|
107
|
+
raise typer.Exit(code=1) from exc
|
|
108
|
+
except TrustlineError as exc:
|
|
109
|
+
logger.exception("validate command failed")
|
|
110
|
+
typer.echo(f"ERROR: {exc}", err=True)
|
|
111
|
+
raise typer.Exit(code=3) from exc
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
logger.exception("unexpected validate failure")
|
|
114
|
+
typer.echo(f"ERROR: internal error: {exc}", err=True)
|
|
115
|
+
raise typer.Exit(code=3) from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Contract-to-SQL check compilation."""
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Compile audit profile documents into SQL checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from trustline.compiler.templates import render_template, resolve_refs
|
|
6
|
+
from trustline.config import Profile
|
|
7
|
+
from trustline.contracts.audit_profile import AuditProfile
|
|
8
|
+
from trustline.executors.base import CheckExpectation, CompiledCheck
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compile_audit_profile_checks(
|
|
12
|
+
profile_doc: AuditProfile,
|
|
13
|
+
profile: Profile,
|
|
14
|
+
dialect: str,
|
|
15
|
+
) -> list[CompiledCheck]:
|
|
16
|
+
"""Compile Phase 1 and Phase 3 checks from an audit profile."""
|
|
17
|
+
checks: list[CompiledCheck] = []
|
|
18
|
+
|
|
19
|
+
sync_table = resolve_refs(profile_doc.crm_coverage.sync_table, profile)
|
|
20
|
+
mirror_table = resolve_refs(profile_doc.crm_coverage.mirror_table, profile)
|
|
21
|
+
checks.append(
|
|
22
|
+
CompiledCheck(
|
|
23
|
+
check_id="audit.crm_coverage",
|
|
24
|
+
phase=1,
|
|
25
|
+
sql=render_template(
|
|
26
|
+
"crm_coverage_gap.sql.j2",
|
|
27
|
+
{"sync_table": sync_table, "mirror_table": mirror_table},
|
|
28
|
+
dialect,
|
|
29
|
+
),
|
|
30
|
+
expect=CheckExpectation(
|
|
31
|
+
kind="min_sync_pct",
|
|
32
|
+
value=profile_doc.crm_coverage.expect_sync_pct,
|
|
33
|
+
),
|
|
34
|
+
evidence_key="crm_coverage",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
swap_table = resolve_refs(profile_doc.source_swap.table, profile)
|
|
39
|
+
migration = profile_doc.source_swap.migration
|
|
40
|
+
checks.append(
|
|
41
|
+
CompiledCheck(
|
|
42
|
+
check_id="audit.source_swap_volume",
|
|
43
|
+
phase=3,
|
|
44
|
+
sql=render_template(
|
|
45
|
+
"source_swap_volume.sql.j2",
|
|
46
|
+
{
|
|
47
|
+
"table_ref": swap_table,
|
|
48
|
+
"from_source": migration.from_source,
|
|
49
|
+
"to_source": migration.to_source,
|
|
50
|
+
"cutover_date": migration.cutover_date.isoformat(),
|
|
51
|
+
},
|
|
52
|
+
dialect,
|
|
53
|
+
),
|
|
54
|
+
expect=CheckExpectation(
|
|
55
|
+
kind="max_drift_pct",
|
|
56
|
+
value=profile_doc.source_swap.volume_threshold_pct,
|
|
57
|
+
tolerance=2.0,
|
|
58
|
+
),
|
|
59
|
+
evidence_key="source_swap",
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if profile_doc.score_distribution is not None:
|
|
64
|
+
table_ref = resolve_refs(profile_doc.score_distribution.table, profile)
|
|
65
|
+
checks.append(
|
|
66
|
+
CompiledCheck(
|
|
67
|
+
check_id="audit.score_distribution",
|
|
68
|
+
phase=3,
|
|
69
|
+
sql=render_template(
|
|
70
|
+
"score_distribution.sql.j2",
|
|
71
|
+
{"table_ref": table_ref},
|
|
72
|
+
dialect,
|
|
73
|
+
),
|
|
74
|
+
expect=CheckExpectation(kind="min_count", value=1.0),
|
|
75
|
+
evidence_key="score_distribution",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return checks
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Compile CohortManifest documents into SQL checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from trustline.compiler.templates import render_template, resolve_refs
|
|
6
|
+
from trustline.config import Profile
|
|
7
|
+
from trustline.contracts.models import CohortManifest
|
|
8
|
+
from trustline.executors.base import CheckExpectation, CompiledCheck
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def compile_cohort_checks(
|
|
12
|
+
contract: CohortManifest,
|
|
13
|
+
profile: Profile,
|
|
14
|
+
dialect: str,
|
|
15
|
+
) -> list[CompiledCheck]:
|
|
16
|
+
"""Compile cohort source parity and positive rate checks."""
|
|
17
|
+
training_table = resolve_refs(contract.spec.sources.training, profile)
|
|
18
|
+
scoring_table = resolve_refs(contract.spec.sources.scoring, profile)
|
|
19
|
+
contract_name = contract.metadata.name
|
|
20
|
+
|
|
21
|
+
source_parity_sql = render_template(
|
|
22
|
+
"cohort_source_parity.sql.j2",
|
|
23
|
+
{
|
|
24
|
+
"training_table": training_table,
|
|
25
|
+
"scoring_table": scoring_table,
|
|
26
|
+
},
|
|
27
|
+
dialect,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
label_sql = contract.spec.label.sql or "1"
|
|
31
|
+
positive_rate_sql = render_template(
|
|
32
|
+
"cohort_positive_rate.sql.j2",
|
|
33
|
+
{
|
|
34
|
+
"label_sql": label_sql,
|
|
35
|
+
"training_table": training_table,
|
|
36
|
+
},
|
|
37
|
+
dialect,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return [
|
|
41
|
+
CompiledCheck(
|
|
42
|
+
check_id=f"cohort.source_parity.{contract_name}",
|
|
43
|
+
phase=4,
|
|
44
|
+
sql=source_parity_sql,
|
|
45
|
+
expect=CheckExpectation(kind="source_parity", value=1.0),
|
|
46
|
+
evidence_key="sources",
|
|
47
|
+
),
|
|
48
|
+
CompiledCheck(
|
|
49
|
+
check_id=f"cohort.positive_rate.{contract_name}",
|
|
50
|
+
phase=4,
|
|
51
|
+
sql=positive_rate_sql,
|
|
52
|
+
expect=CheckExpectation(
|
|
53
|
+
kind="positive_rate",
|
|
54
|
+
value=contract.spec.expected_positive_rate,
|
|
55
|
+
tolerance=contract.spec.positive_rate_tolerance,
|
|
56
|
+
),
|
|
57
|
+
evidence_key="positive_rate",
|
|
58
|
+
),
|
|
59
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Compile FunnelContract documents into SQL checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from trustline.compiler.templates import render_template, resolve_refs
|
|
6
|
+
from trustline.config import Profile
|
|
7
|
+
from trustline.contracts.models import FunnelContract, FunnelStage
|
|
8
|
+
from trustline.exceptions import AuditError
|
|
9
|
+
from trustline.executors.base import CheckExpectation, CompiledCheck
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _stage_subquery(
|
|
13
|
+
stage: FunnelStage,
|
|
14
|
+
stage_queries: dict[str, str],
|
|
15
|
+
profile: Profile,
|
|
16
|
+
dialect: str,
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Build the SQL subquery that materializes a funnel stage."""
|
|
19
|
+
if stage.sql is not None:
|
|
20
|
+
return resolve_refs(stage.sql, profile)
|
|
21
|
+
|
|
22
|
+
if stage.from_stage is None or stage.join is None:
|
|
23
|
+
msg = f"stage {stage.name!r} is missing sql or join configuration"
|
|
24
|
+
raise AuditError(msg)
|
|
25
|
+
|
|
26
|
+
return render_template(
|
|
27
|
+
"funnel_stage_join.sql.j2",
|
|
28
|
+
{
|
|
29
|
+
"prior_stage_sql": stage_queries[stage.from_stage],
|
|
30
|
+
"join_table": resolve_refs(stage.join.table, profile),
|
|
31
|
+
"join_type": stage.join.type.upper(),
|
|
32
|
+
"join_on": stage.join.on,
|
|
33
|
+
},
|
|
34
|
+
dialect,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_stage_queries(
|
|
39
|
+
contract: FunnelContract, profile: Profile, dialect: str
|
|
40
|
+
) -> dict[str, str]:
|
|
41
|
+
"""Compile all stage subqueries in funnel order."""
|
|
42
|
+
queries: dict[str, str] = {}
|
|
43
|
+
for stage in contract.spec.stages:
|
|
44
|
+
queries[stage.name] = _stage_subquery(stage, queries, profile, dialect)
|
|
45
|
+
return queries
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compile_funnel_checks(
|
|
49
|
+
contract: FunnelContract,
|
|
50
|
+
profile: Profile,
|
|
51
|
+
dialect: str,
|
|
52
|
+
) -> list[CompiledCheck]:
|
|
53
|
+
"""Compile funnel stage count and retention checks."""
|
|
54
|
+
checks: list[CompiledCheck] = []
|
|
55
|
+
stage_queries = _build_stage_queries(contract, profile, dialect)
|
|
56
|
+
|
|
57
|
+
for index, stage in enumerate(contract.spec.stages):
|
|
58
|
+
stage_sql = stage_queries[stage.name]
|
|
59
|
+
contract_name = contract.metadata.name
|
|
60
|
+
|
|
61
|
+
if stage.expect_min_count is not None:
|
|
62
|
+
sql = render_template(
|
|
63
|
+
"funnel_stage_count.sql.j2",
|
|
64
|
+
{"stage_sql": stage_sql, "stage_name": stage.name},
|
|
65
|
+
dialect,
|
|
66
|
+
)
|
|
67
|
+
checks.append(
|
|
68
|
+
CompiledCheck(
|
|
69
|
+
check_id=f"funnel.count.{contract_name}.{stage.name}",
|
|
70
|
+
phase=2,
|
|
71
|
+
sql=sql,
|
|
72
|
+
expect=CheckExpectation(kind="min_count", value=float(stage.expect_min_count)),
|
|
73
|
+
evidence_key=stage.name,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if stage.expect_retention_pct is not None and index > 0:
|
|
78
|
+
prior_stage = contract.spec.stages[index - 1]
|
|
79
|
+
sql = render_template(
|
|
80
|
+
"funnel_retention.sql.j2",
|
|
81
|
+
{
|
|
82
|
+
"stage_sql": stage_sql,
|
|
83
|
+
"prior_stage_sql": stage_queries[prior_stage.name],
|
|
84
|
+
},
|
|
85
|
+
dialect,
|
|
86
|
+
)
|
|
87
|
+
checks.append(
|
|
88
|
+
CompiledCheck(
|
|
89
|
+
check_id=f"funnel.retention.{contract_name}.{stage.name}",
|
|
90
|
+
phase=2,
|
|
91
|
+
sql=sql,
|
|
92
|
+
expect=CheckExpectation(
|
|
93
|
+
kind="min_retention_pct",
|
|
94
|
+
value=float(stage.expect_retention_pct),
|
|
95
|
+
),
|
|
96
|
+
evidence_key=stage.name,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return checks
|