data-contract-validator 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data_contract_validator/__init__.py +24 -0
- data_contract_validator/cli.py +672 -0
- data_contract_validator/core/__init__.py +0 -0
- data_contract_validator/core/models.py +115 -0
- data_contract_validator/core/validator.py +187 -0
- data_contract_validator/extractors/__init__.py +14 -0
- data_contract_validator/extractors/base.py +45 -0
- data_contract_validator/extractors/dbt.py +213 -0
- data_contract_validator/extractors/fastapi.py +200 -0
- data_contract_validator/integrations/__init__.py +0 -0
- data_contract_validator/py.typed +2 -0
- data_contract_validator/templates/github-actions-template.yml +75 -0
- data_contract_validator-1.0.0.dist-info/METADATA +344 -0
- data_contract_validator-1.0.0.dist-info/RECORD +18 -0
- data_contract_validator-1.0.0.dist-info/WHEEL +5 -0
- data_contract_validator-1.0.0.dist-info/entry_points.txt +3 -0
- data_contract_validator-1.0.0.dist-info/licenses/LICENSE +21 -0
- data_contract_validator-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data Contract Validator
|
|
3
|
+
|
|
4
|
+
Prevent production API breaks by validating data contracts between
|
|
5
|
+
your data pipelines and API frameworks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "1.0.0"
|
|
9
|
+
__author__ = "Your Name"
|
|
10
|
+
__email__ = "your.email@example.com"
|
|
11
|
+
|
|
12
|
+
from .core.validator import ContractValidator
|
|
13
|
+
from .core.models import ValidationResult, ValidationIssue, IssueSeverity
|
|
14
|
+
from .extractors.dbt import DBTExtractor
|
|
15
|
+
from .extractors.fastapi import FastAPIExtractor
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ContractValidator",
|
|
19
|
+
"ValidationResult",
|
|
20
|
+
"ValidationIssue",
|
|
21
|
+
"IssueSeverity",
|
|
22
|
+
"DBTExtractor",
|
|
23
|
+
"FastAPIExtractor",
|
|
24
|
+
]
|
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import yaml
|
|
5
|
+
import subprocess
|
|
6
|
+
import click
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Dict, Any
|
|
9
|
+
|
|
10
|
+
from .core.validator import ContractValidator
|
|
11
|
+
from .extractors.dbt import DBTExtractor
|
|
12
|
+
from .extractors.fastapi import FastAPIExtractor
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
@click.version_option()
|
|
17
|
+
def cli():
|
|
18
|
+
"""๐ก๏ธ Data Contract Validator - Prevent production API breaks with lightweight governance."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@cli.command()
|
|
23
|
+
@click.option(
|
|
24
|
+
"--interactive", is_flag=True, help="Interactive setup wizard (recommended)"
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--framework",
|
|
28
|
+
type=click.Choice(["fastapi", "django", "flask"]),
|
|
29
|
+
help="Target framework",
|
|
30
|
+
)
|
|
31
|
+
@click.option("--dbt-path", default=".", help="DBT project path")
|
|
32
|
+
@click.option("--output-dir", default=".", help="Output directory")
|
|
33
|
+
def init(interactive: bool, framework: str, dbt_path: str, output_dir: str):
|
|
34
|
+
"""๐ Initialize contract validation for your project (takes 30 seconds)."""
|
|
35
|
+
|
|
36
|
+
click.echo("๐ก๏ธ Setting up Data Contract Validation...")
|
|
37
|
+
click.echo(" This prevents production breaks forever!")
|
|
38
|
+
click.echo()
|
|
39
|
+
|
|
40
|
+
if interactive:
|
|
41
|
+
config = _interactive_setup()
|
|
42
|
+
else:
|
|
43
|
+
config = _quick_setup(framework, dbt_path)
|
|
44
|
+
|
|
45
|
+
output_path = Path(output_dir)
|
|
46
|
+
|
|
47
|
+
# Write config file
|
|
48
|
+
config_file = output_path / ".retl-validator.yml"
|
|
49
|
+
with open(config_file, "w") as f:
|
|
50
|
+
yaml.dump(config, f, default_flow_style=False, indent=2)
|
|
51
|
+
|
|
52
|
+
click.echo(f"โ
Created configuration: {config_file}")
|
|
53
|
+
|
|
54
|
+
# Create GitHub Actions workflow
|
|
55
|
+
if _create_github_workflow(output_path, config):
|
|
56
|
+
click.echo("โ
Created GitHub Actions workflow")
|
|
57
|
+
|
|
58
|
+
# Test the setup
|
|
59
|
+
click.echo("\n๐งช Testing your setup...")
|
|
60
|
+
if _test_setup(config_file):
|
|
61
|
+
click.echo("\n๐ Setup complete! Your contracts are now protected.")
|
|
62
|
+
click.echo("\n๐ Next steps:")
|
|
63
|
+
click.echo(" 1. git add .retl-validator.yml .github/workflows/")
|
|
64
|
+
click.echo(" 2. git commit -m 'Add data contract validation'")
|
|
65
|
+
click.echo(" 3. git push (triggers validation in CI/CD)")
|
|
66
|
+
click.echo(" 4. Watch it prevent production breaks! ๐ก๏ธ")
|
|
67
|
+
else:
|
|
68
|
+
click.echo(
|
|
69
|
+
"\nโ ๏ธ Setup needs attention. Run 'contract-validator test' for details."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _interactive_setup() -> Dict[str, Any]:
|
|
74
|
+
"""Interactive setup wizard - 3 simple questions."""
|
|
75
|
+
click.echo("๐ Quick Setup (3 questions):")
|
|
76
|
+
click.echo()
|
|
77
|
+
|
|
78
|
+
# Question 1: DBT project location
|
|
79
|
+
dbt_path = click.prompt(
|
|
80
|
+
"1๏ธโฃ Where is your DBT project?", default=".", show_default=True
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Auto-detect if DBT project exists
|
|
84
|
+
if not Path(dbt_path).exists() or not (Path(dbt_path) / "dbt_project.yml").exists():
|
|
85
|
+
click.echo(f" โ ๏ธ No dbt_project.yml found at {dbt_path}")
|
|
86
|
+
if click.confirm(" Continue anyway?"):
|
|
87
|
+
pass
|
|
88
|
+
else:
|
|
89
|
+
click.echo(" ๐ก Make sure you're in your DBT project directory")
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
else:
|
|
92
|
+
click.echo(" โ
DBT project found")
|
|
93
|
+
|
|
94
|
+
# Question 2: API framework
|
|
95
|
+
click.echo()
|
|
96
|
+
framework = click.prompt(
|
|
97
|
+
"2๏ธโฃ What API framework do you use?",
|
|
98
|
+
type=click.Choice(["fastapi", "django", "flask", "other"]),
|
|
99
|
+
default="fastapi",
|
|
100
|
+
show_default=True,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Question 3: API models location
|
|
104
|
+
click.echo()
|
|
105
|
+
if framework == "fastapi":
|
|
106
|
+
default_path = "app/models.py"
|
|
107
|
+
prompt_text = "3๏ธโฃ Where are your Pydantic models?"
|
|
108
|
+
elif framework == "django":
|
|
109
|
+
default_path = "models.py"
|
|
110
|
+
prompt_text = "3๏ธโฃ Where are your Django models?"
|
|
111
|
+
else:
|
|
112
|
+
default_path = "models.py"
|
|
113
|
+
prompt_text = "3๏ธโฃ Where are your API models?"
|
|
114
|
+
|
|
115
|
+
api_location = click.prompt(prompt_text, default=default_path, show_default=True)
|
|
116
|
+
|
|
117
|
+
# Auto-detect if it's local file or GitHub repo
|
|
118
|
+
is_github_repo = "/" in api_location and not api_location.startswith((".", "/"))
|
|
119
|
+
|
|
120
|
+
if is_github_repo:
|
|
121
|
+
# Format: "org/repo" or "org/repo/path/to/file.py"
|
|
122
|
+
parts = api_location.split("/")
|
|
123
|
+
if len(parts) >= 2:
|
|
124
|
+
repo = "/".join(parts[:2])
|
|
125
|
+
path = "/".join(parts[2:]) if len(parts) > 2 else "models.py"
|
|
126
|
+
else:
|
|
127
|
+
repo = api_location
|
|
128
|
+
path = "models.py"
|
|
129
|
+
|
|
130
|
+
api_config = {"type": "github", "repo": repo, "path": path}
|
|
131
|
+
click.echo(f" ๐ GitHub repo detected: {repo}/{path}")
|
|
132
|
+
else:
|
|
133
|
+
api_config = {"type": "local", "path": api_location}
|
|
134
|
+
|
|
135
|
+
# Check if local file exists
|
|
136
|
+
if Path(api_location).exists():
|
|
137
|
+
click.echo(" โ
Local file found")
|
|
138
|
+
else:
|
|
139
|
+
click.echo(f" โ ๏ธ File not found: {api_location}")
|
|
140
|
+
if not click.confirm(" Continue anyway?"):
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"version": "1.0",
|
|
145
|
+
"name": f"contracts-{Path.cwd().name}",
|
|
146
|
+
"description": "Auto-generated data contract validation",
|
|
147
|
+
"source": {
|
|
148
|
+
"dbt": {"project_path": dbt_path, "auto_compile": True, "timeout": 120}
|
|
149
|
+
},
|
|
150
|
+
"target": {framework: api_config},
|
|
151
|
+
"validation": {
|
|
152
|
+
"fail_on": ["missing_tables", "missing_required_columns"],
|
|
153
|
+
"warn_on": ["type_mismatches", "missing_optional_columns"],
|
|
154
|
+
"mode": "strict",
|
|
155
|
+
},
|
|
156
|
+
"output": {"format": "terminal", "show_suggestions": True, "max_issues": 20},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _quick_setup(framework: str, dbt_path: str) -> Dict[str, Any]:
|
|
161
|
+
"""Quick non-interactive setup with smart defaults."""
|
|
162
|
+
|
|
163
|
+
click.echo("๐ Auto-detecting project structure...")
|
|
164
|
+
|
|
165
|
+
# Auto-detect API models location
|
|
166
|
+
framework = framework or "fastapi"
|
|
167
|
+
|
|
168
|
+
common_paths = {
|
|
169
|
+
"fastapi": ["app/models.py", "src/models.py", "models.py", "api/models.py"],
|
|
170
|
+
"django": ["models.py", "*/models.py", "app/models.py"],
|
|
171
|
+
"flask": ["models.py", "app/models.py", "src/models.py"],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
api_path = None
|
|
175
|
+
if framework in common_paths:
|
|
176
|
+
for path in common_paths[framework]:
|
|
177
|
+
if "*" in path:
|
|
178
|
+
# Handle wildcard patterns
|
|
179
|
+
import glob
|
|
180
|
+
|
|
181
|
+
matches = glob.glob(path)
|
|
182
|
+
if matches:
|
|
183
|
+
api_path = matches[0]
|
|
184
|
+
break
|
|
185
|
+
elif Path(path).exists():
|
|
186
|
+
api_path = path
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
if api_path:
|
|
190
|
+
click.echo(f" โ
Found {framework} models: {api_path}")
|
|
191
|
+
else:
|
|
192
|
+
api_path = common_paths[framework][0] # Use default
|
|
193
|
+
click.echo(f" โ ๏ธ Using default path: {api_path}")
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"version": "1.0",
|
|
197
|
+
"name": f"contracts-{Path.cwd().name}",
|
|
198
|
+
"description": "Auto-generated data contract validation",
|
|
199
|
+
"source": {"dbt": {"project_path": dbt_path, "auto_compile": True}},
|
|
200
|
+
"target": {framework: {"type": "local", "path": api_path}},
|
|
201
|
+
"validation": {
|
|
202
|
+
"fail_on": ["missing_tables", "missing_required_columns"],
|
|
203
|
+
"warn_on": ["type_mismatches"],
|
|
204
|
+
"mode": "balanced", # Less strict than interactive
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _create_github_workflow(output_path: Path, config: Dict[str, Any]) -> bool:
|
|
210
|
+
"""Auto-create GitHub Actions workflow."""
|
|
211
|
+
|
|
212
|
+
workflow_dir = output_path / ".github" / "workflows"
|
|
213
|
+
workflow_dir.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
|
|
215
|
+
# Determine trigger paths based on config
|
|
216
|
+
dbt_path = config.get("source", {}).get("dbt", {}).get("project_path", ".")
|
|
217
|
+
|
|
218
|
+
trigger_paths = [
|
|
219
|
+
f"{dbt_path}/models/**/*.sql" if dbt_path != "." else "models/**/*.sql",
|
|
220
|
+
f"{dbt_path}/dbt_project.yml" if dbt_path != "." else "dbt_project.yml",
|
|
221
|
+
"**/*models*.py",
|
|
222
|
+
".retl-validator.yml",
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
workflow_content = f"""# ๐ค Auto-generated by data-contract-validator
|
|
226
|
+
name: ๐ก๏ธ Data Contract Validation
|
|
227
|
+
|
|
228
|
+
on:
|
|
229
|
+
pull_request:
|
|
230
|
+
paths:
|
|
231
|
+
{chr(10).join(f' - "{path}"' for path in trigger_paths)}
|
|
232
|
+
|
|
233
|
+
workflow_dispatch:
|
|
234
|
+
|
|
235
|
+
permissions:
|
|
236
|
+
contents: read
|
|
237
|
+
pull-requests: write
|
|
238
|
+
|
|
239
|
+
jobs:
|
|
240
|
+
validate-contracts:
|
|
241
|
+
name: Validate Data Contracts
|
|
242
|
+
runs-on: ubuntu-latest
|
|
243
|
+
|
|
244
|
+
steps:
|
|
245
|
+
- name: Checkout code
|
|
246
|
+
uses: actions/checkout@v4
|
|
247
|
+
|
|
248
|
+
- name: Setup Python
|
|
249
|
+
uses: actions/setup-python@v4
|
|
250
|
+
with:
|
|
251
|
+
python-version: '3.9'
|
|
252
|
+
|
|
253
|
+
- name: Install data contract validator
|
|
254
|
+
run: pip install data-contract-validator
|
|
255
|
+
|
|
256
|
+
- name: Validate contracts
|
|
257
|
+
env:
|
|
258
|
+
GITHUB_TOKEN: ${{{{ secrets.GITHUB_TOKEN }}}}
|
|
259
|
+
run: |
|
|
260
|
+
contract-validator validate \\
|
|
261
|
+
--config .retl-validator.yml \\
|
|
262
|
+
--output github
|
|
263
|
+
|
|
264
|
+
- name: Comment on PR (if validation fails)
|
|
265
|
+
if: failure()
|
|
266
|
+
uses: actions/github-script@v6
|
|
267
|
+
with:
|
|
268
|
+
script: |
|
|
269
|
+
github.rest.issues.createComment({{
|
|
270
|
+
issue_number: context.issue.number,
|
|
271
|
+
owner: context.repo.owner,
|
|
272
|
+
repo: context.repo.repo,
|
|
273
|
+
body: `## ๐จ Data Contract Validation Failed
|
|
274
|
+
|
|
275
|
+
Your changes don't satisfy API requirements.
|
|
276
|
+
Check the logs above for specific issues.
|
|
277
|
+
|
|
278
|
+
**Common fixes:**
|
|
279
|
+
- Add missing columns to your DBT model
|
|
280
|
+
- Update API models to match DBT output
|
|
281
|
+
- Check for type mismatches
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
๐ค Automated by [Data Contract Validator](https://github.com/OGsiji/retl_validator)`
|
|
285
|
+
}})
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
workflow_file = workflow_dir / "validate-contracts.yml"
|
|
289
|
+
try:
|
|
290
|
+
with open(workflow_file, "w") as f:
|
|
291
|
+
f.write(workflow_content)
|
|
292
|
+
return True
|
|
293
|
+
except Exception as e:
|
|
294
|
+
click.echo(f" โ ๏ธ Could not create workflow: {e}")
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@cli.command()
|
|
299
|
+
def test():
|
|
300
|
+
"""๐งช Test your contract validation setup."""
|
|
301
|
+
|
|
302
|
+
click.echo("๐งช Testing Data Contract Validation Setup...")
|
|
303
|
+
click.echo("=" * 45)
|
|
304
|
+
|
|
305
|
+
config_file = Path(".retl-validator.yml")
|
|
306
|
+
return _test_setup(config_file)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _test_setup(config_file: Path) -> bool:
|
|
310
|
+
"""Internal setup test with detailed output."""
|
|
311
|
+
|
|
312
|
+
all_passed = True
|
|
313
|
+
|
|
314
|
+
# Test 1: Config file exists
|
|
315
|
+
click.echo("\n1๏ธโฃ Checking configuration file...")
|
|
316
|
+
if not config_file.exists():
|
|
317
|
+
click.echo(f" โ No {config_file} found")
|
|
318
|
+
click.echo(" ๐ก Run 'contract-validator init' first")
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
click.echo(f" โ
Configuration file found: {config_file}")
|
|
322
|
+
|
|
323
|
+
# Test 2: Load and validate config
|
|
324
|
+
click.echo("\n2๏ธโฃ Validating configuration...")
|
|
325
|
+
try:
|
|
326
|
+
with open(config_file) as f:
|
|
327
|
+
config = yaml.safe_load(f)
|
|
328
|
+
click.echo(" โ
Configuration is valid YAML")
|
|
329
|
+
|
|
330
|
+
# Check required sections
|
|
331
|
+
required_sections = ["version", "source", "target", "validation"]
|
|
332
|
+
missing_sections = [s for s in required_sections if s not in config]
|
|
333
|
+
if missing_sections:
|
|
334
|
+
click.echo(f" โ ๏ธ Missing sections: {missing_sections}")
|
|
335
|
+
all_passed = False
|
|
336
|
+
else:
|
|
337
|
+
click.echo(" โ
All required sections present")
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
click.echo(f" โ Configuration file is invalid: {e}")
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
# Test 3: Check DBT project
|
|
344
|
+
click.echo("\n3๏ธโฃ Checking DBT project...")
|
|
345
|
+
dbt_config = config.get("source", {}).get("dbt", {})
|
|
346
|
+
dbt_path = Path(dbt_config.get("project_path", "."))
|
|
347
|
+
|
|
348
|
+
if not dbt_path.exists():
|
|
349
|
+
click.echo(f" โ DBT project directory not found: {dbt_path}")
|
|
350
|
+
all_passed = False
|
|
351
|
+
elif not (dbt_path / "dbt_project.yml").exists():
|
|
352
|
+
click.echo(f" โ ๏ธ No dbt_project.yml found in {dbt_path}")
|
|
353
|
+
click.echo(" ๐ก Make sure this is a valid DBT project")
|
|
354
|
+
all_passed = False
|
|
355
|
+
else:
|
|
356
|
+
click.echo(f" โ
DBT project found: {dbt_path}")
|
|
357
|
+
|
|
358
|
+
# Test 4: Check target configuration
|
|
359
|
+
click.echo("\n4๏ธโฃ Checking target configuration...")
|
|
360
|
+
target_config = config.get("target", {})
|
|
361
|
+
|
|
362
|
+
if not target_config:
|
|
363
|
+
click.echo(" โ No target configuration found")
|
|
364
|
+
all_passed = False
|
|
365
|
+
else:
|
|
366
|
+
for target_name, target_info in target_config.items():
|
|
367
|
+
click.echo(f" ๐ฏ Target: {target_name}")
|
|
368
|
+
|
|
369
|
+
if target_info.get("type") == "local":
|
|
370
|
+
api_path = Path(target_info.get("path", ""))
|
|
371
|
+
if not api_path.exists():
|
|
372
|
+
click.echo(f" โ ๏ธ Local file not found: {api_path}")
|
|
373
|
+
all_passed = False
|
|
374
|
+
else:
|
|
375
|
+
click.echo(f" โ
Local file found: {api_path}")
|
|
376
|
+
|
|
377
|
+
elif target_info.get("type") == "github":
|
|
378
|
+
repo = target_info.get("repo")
|
|
379
|
+
path = target_info.get("path")
|
|
380
|
+
click.echo(f" ๐ GitHub repo: {repo}/{path}")
|
|
381
|
+
|
|
382
|
+
else:
|
|
383
|
+
click.echo(f" โ ๏ธ Unknown target type: {target_info.get('type')}")
|
|
384
|
+
all_passed = False
|
|
385
|
+
|
|
386
|
+
# Test 5: Try a dry run validation
|
|
387
|
+
click.echo("\n5๏ธโฃ Testing validation...")
|
|
388
|
+
try:
|
|
389
|
+
from .core.validator import ContractValidator
|
|
390
|
+
from .extractors.dbt import DBTExtractor
|
|
391
|
+
from .extractors.fastapi import FastAPIExtractor
|
|
392
|
+
|
|
393
|
+
# Quick validation test
|
|
394
|
+
dbt_extractor = DBTExtractor(str(dbt_path))
|
|
395
|
+
|
|
396
|
+
# Test DBT extraction
|
|
397
|
+
click.echo(" ๐ Testing DBT extraction...")
|
|
398
|
+
dbt_schemas = dbt_extractor.extract_schemas()
|
|
399
|
+
if dbt_schemas:
|
|
400
|
+
click.echo(f" โ
Found {len(dbt_schemas)} DBT models")
|
|
401
|
+
else:
|
|
402
|
+
click.echo(" โ ๏ธ No DBT models found")
|
|
403
|
+
all_passed = False
|
|
404
|
+
|
|
405
|
+
# Test target extraction (for local files only)
|
|
406
|
+
for target_name, target_info in target_config.items():
|
|
407
|
+
if target_info.get("type") == "local":
|
|
408
|
+
click.echo(f" ๐ฏ Testing {target_name} extraction...")
|
|
409
|
+
try:
|
|
410
|
+
if target_name == "fastapi":
|
|
411
|
+
target_extractor = FastAPIExtractor.from_local_file(
|
|
412
|
+
target_info.get("path")
|
|
413
|
+
)
|
|
414
|
+
target_schemas = target_extractor.extract_schemas()
|
|
415
|
+
if target_schemas:
|
|
416
|
+
click.echo(
|
|
417
|
+
f" โ
Found {len(target_schemas)} API models"
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
click.echo(" โ ๏ธ No API models found")
|
|
421
|
+
all_passed = False
|
|
422
|
+
except Exception as e:
|
|
423
|
+
click.echo(f" โ ๏ธ Extraction error: {e}")
|
|
424
|
+
all_passed = False
|
|
425
|
+
|
|
426
|
+
except Exception as e:
|
|
427
|
+
click.echo(f" โ ๏ธ Validation test error: {e}")
|
|
428
|
+
all_passed = False
|
|
429
|
+
|
|
430
|
+
# Final result
|
|
431
|
+
click.echo("\n" + "=" * 45)
|
|
432
|
+
if all_passed:
|
|
433
|
+
click.echo("๐ All tests passed! Your setup is ready.")
|
|
434
|
+
click.echo("\n๐ Next steps:")
|
|
435
|
+
click.echo(" โข Run 'contract-validator validate' to test validation")
|
|
436
|
+
click.echo(" โข Commit your config and workflow files")
|
|
437
|
+
click.echo(" โข Push to activate protection in CI/CD")
|
|
438
|
+
else:
|
|
439
|
+
click.echo("โ ๏ธ Some tests had issues. See details above.")
|
|
440
|
+
click.echo("\n๐ก Common fixes:")
|
|
441
|
+
click.echo(" โข Make sure you're in your DBT project directory")
|
|
442
|
+
click.echo(" โข Check that API model files exist")
|
|
443
|
+
click.echo(" โข Run 'contract-validator init' to regenerate config")
|
|
444
|
+
|
|
445
|
+
return all_passed
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@cli.command()
|
|
449
|
+
@click.option("--config", default=".retl-validator.yml", help="Config file path")
|
|
450
|
+
@click.option(
|
|
451
|
+
"--dry-run", is_flag=True, help="Test configuration without full validation"
|
|
452
|
+
)
|
|
453
|
+
@click.option(
|
|
454
|
+
"--output", type=click.Choice(["terminal", "json", "github"]), default="terminal"
|
|
455
|
+
)
|
|
456
|
+
@click.option("--dbt-project", help="Override DBT project path")
|
|
457
|
+
@click.option("--fastapi-local", help="Override FastAPI models path")
|
|
458
|
+
@click.option("--fastapi-repo", help="Override FastAPI repo (org/repo)")
|
|
459
|
+
@click.option("--fastapi-path", default="app/models.py", help="Path in FastAPI repo")
|
|
460
|
+
def validate(
|
|
461
|
+
config: str,
|
|
462
|
+
dry_run: bool,
|
|
463
|
+
output: str,
|
|
464
|
+
dbt_project: str,
|
|
465
|
+
fastapi_local: str,
|
|
466
|
+
fastapi_repo: str,
|
|
467
|
+
fastapi_path: str,
|
|
468
|
+
):
|
|
469
|
+
"""๐ Validate data contracts (prevents production breaks)."""
|
|
470
|
+
|
|
471
|
+
# Load config if it exists
|
|
472
|
+
config_data = {}
|
|
473
|
+
config_file = Path(config)
|
|
474
|
+
if config_file.exists():
|
|
475
|
+
with open(config_file) as f:
|
|
476
|
+
config_data = yaml.safe_load(f)
|
|
477
|
+
click.echo(f"๐ Using config: {config}")
|
|
478
|
+
elif not any([dbt_project, fastapi_local, fastapi_repo]):
|
|
479
|
+
click.echo("โ No config file found and no command line options provided")
|
|
480
|
+
click.echo("๐ก Run 'contract-validator init' to create a config file")
|
|
481
|
+
click.echo(" Or use command line options:")
|
|
482
|
+
click.echo(
|
|
483
|
+
" contract-validator validate --dbt-project . --fastapi-local app/models.py"
|
|
484
|
+
)
|
|
485
|
+
sys.exit(1)
|
|
486
|
+
|
|
487
|
+
if dry_run:
|
|
488
|
+
click.echo("๐งช Dry run - testing configuration only")
|
|
489
|
+
_test_configuration(config_data, dbt_project, fastapi_local, fastapi_repo)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
# Run actual validation
|
|
493
|
+
_run_validation(
|
|
494
|
+
config_data, output, dbt_project, fastapi_local, fastapi_repo, fastapi_path
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _test_configuration(
|
|
499
|
+
config_data: Dict[str, Any], dbt_project: str, fastapi_local: str, fastapi_repo: str
|
|
500
|
+
):
|
|
501
|
+
"""Test configuration without running full validation."""
|
|
502
|
+
|
|
503
|
+
dbt_path = dbt_project or config_data.get("source", {}).get("dbt", {}).get(
|
|
504
|
+
"project_path", "."
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
click.echo(f" ๐ DBT project: {dbt_path}")
|
|
508
|
+
if Path(dbt_path).exists():
|
|
509
|
+
click.echo(" โ
Path exists")
|
|
510
|
+
else:
|
|
511
|
+
click.echo(" โ Path not found")
|
|
512
|
+
|
|
513
|
+
if fastapi_local:
|
|
514
|
+
click.echo(f" ๐ฏ FastAPI models: {fastapi_local}")
|
|
515
|
+
if Path(fastapi_local).exists():
|
|
516
|
+
click.echo(" โ
File exists")
|
|
517
|
+
else:
|
|
518
|
+
click.echo(" โ File not found")
|
|
519
|
+
|
|
520
|
+
if fastapi_repo:
|
|
521
|
+
click.echo(f" ๐ FastAPI repo: {fastapi_repo}")
|
|
522
|
+
|
|
523
|
+
click.echo("โ
Configuration test complete!")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _run_validation(
|
|
527
|
+
config_data: Dict[str, Any],
|
|
528
|
+
output: str,
|
|
529
|
+
dbt_project: str,
|
|
530
|
+
fastapi_local: str,
|
|
531
|
+
fastapi_repo: str,
|
|
532
|
+
fastapi_path: str,
|
|
533
|
+
):
|
|
534
|
+
"""Run the actual validation."""
|
|
535
|
+
|
|
536
|
+
# Get DBT project path
|
|
537
|
+
dbt_path = dbt_project or config_data.get("source", {}).get("dbt", {}).get(
|
|
538
|
+
"project_path", "."
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
# Initialize DBT extractor
|
|
542
|
+
try:
|
|
543
|
+
dbt_extractor = DBTExtractor(dbt_path)
|
|
544
|
+
except Exception as e:
|
|
545
|
+
click.echo(f"โ Error initializing DBT extractor: {e}")
|
|
546
|
+
sys.exit(1)
|
|
547
|
+
|
|
548
|
+
# Initialize FastAPI extractor
|
|
549
|
+
try:
|
|
550
|
+
if fastapi_local:
|
|
551
|
+
fastapi_extractor = FastAPIExtractor.from_local_file(fastapi_local)
|
|
552
|
+
elif fastapi_repo:
|
|
553
|
+
github_token = os.environ.get("GITHUB_TOKEN")
|
|
554
|
+
fastapi_extractor = FastAPIExtractor.from_github_repo(
|
|
555
|
+
repo=fastapi_repo, path=fastapi_path, token=github_token
|
|
556
|
+
)
|
|
557
|
+
else:
|
|
558
|
+
# Get from config
|
|
559
|
+
target_config = list(config_data.get("target", {}).values())[0]
|
|
560
|
+
if target_config.get("type") == "local":
|
|
561
|
+
fastapi_extractor = FastAPIExtractor.from_local_file(
|
|
562
|
+
target_config.get("path")
|
|
563
|
+
)
|
|
564
|
+
elif target_config.get("type") == "github":
|
|
565
|
+
github_token = os.environ.get("GITHUB_TOKEN")
|
|
566
|
+
fastapi_extractor = FastAPIExtractor.from_github_repo(
|
|
567
|
+
repo=target_config.get("repo"),
|
|
568
|
+
path=target_config.get("path", "app/models.py"),
|
|
569
|
+
token=github_token,
|
|
570
|
+
)
|
|
571
|
+
else:
|
|
572
|
+
click.echo("โ No valid FastAPI configuration found")
|
|
573
|
+
sys.exit(1)
|
|
574
|
+
|
|
575
|
+
except Exception as e:
|
|
576
|
+
click.echo(f"โ Error initializing FastAPI extractor: {e}")
|
|
577
|
+
sys.exit(1)
|
|
578
|
+
|
|
579
|
+
# Run validation
|
|
580
|
+
try:
|
|
581
|
+
validator = ContractValidator(
|
|
582
|
+
source_extractor=dbt_extractor, target_extractor=fastapi_extractor
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
result = validator.validate()
|
|
586
|
+
|
|
587
|
+
# Output results
|
|
588
|
+
if output == "json":
|
|
589
|
+
click.echo(json.dumps(result.to_dict(), indent=2))
|
|
590
|
+
elif output == "github":
|
|
591
|
+
_output_github_actions(result)
|
|
592
|
+
else:
|
|
593
|
+
_output_terminal(result)
|
|
594
|
+
|
|
595
|
+
# Exit with appropriate code
|
|
596
|
+
validation_config = config_data.get("validation", {})
|
|
597
|
+
fail_on = validation_config.get(
|
|
598
|
+
"fail_on", ["missing_tables", "missing_required_columns"]
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
if "missing_tables" in fail_on and any(
|
|
602
|
+
"Missing Table" in issue.category for issue in result.critical_issues
|
|
603
|
+
):
|
|
604
|
+
sys.exit(1)
|
|
605
|
+
elif "missing_required_columns" in fail_on and any(
|
|
606
|
+
"Missing Column" in issue.category for issue in result.critical_issues
|
|
607
|
+
):
|
|
608
|
+
sys.exit(1)
|
|
609
|
+
elif result.critical_issues:
|
|
610
|
+
sys.exit(1)
|
|
611
|
+
|
|
612
|
+
except Exception as e:
|
|
613
|
+
click.echo(f"โ Validation error: {e}")
|
|
614
|
+
sys.exit(1)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _output_terminal(result):
|
|
618
|
+
"""Output results to terminal with emojis and colors."""
|
|
619
|
+
click.echo(f"\n๐ก๏ธ Data Contract Validation Results:")
|
|
620
|
+
click.echo("=" * 45)
|
|
621
|
+
click.echo(f"Status: {'โ
PASSED' if result.success else 'โ FAILED'}")
|
|
622
|
+
click.echo(f"Total issues: {len(result.issues)}")
|
|
623
|
+
click.echo(f"Critical: {len(result.critical_issues)}")
|
|
624
|
+
click.echo(f"Warnings: {len(result.warnings)}")
|
|
625
|
+
|
|
626
|
+
if result.critical_issues:
|
|
627
|
+
click.echo("\n๐จ Critical Issues (Must Fix):")
|
|
628
|
+
for issue in result.critical_issues:
|
|
629
|
+
click.echo(f" ๐ฅ {issue.table}")
|
|
630
|
+
if issue.column:
|
|
631
|
+
click.echo(f" Column: {issue.column}")
|
|
632
|
+
click.echo(f" Problem: {issue.message}")
|
|
633
|
+
if issue.suggested_fix:
|
|
634
|
+
click.echo(f" ๐ง Fix: {issue.suggested_fix}")
|
|
635
|
+
click.echo()
|
|
636
|
+
|
|
637
|
+
if result.warnings and not result.critical_issues:
|
|
638
|
+
click.echo("\nโ ๏ธ Warnings (Good to Know):")
|
|
639
|
+
for issue in result.warnings[:5]:
|
|
640
|
+
click.echo(f" โ ๏ธ {issue.table}.{issue.column}: {issue.message}")
|
|
641
|
+
|
|
642
|
+
if len(result.warnings) > 5:
|
|
643
|
+
click.echo(f" ... and {len(result.warnings) - 5} more warnings")
|
|
644
|
+
|
|
645
|
+
click.echo(f"\n{result.summary}")
|
|
646
|
+
|
|
647
|
+
if result.success:
|
|
648
|
+
click.echo("\n๐ Great! Your API contracts are protected.")
|
|
649
|
+
else:
|
|
650
|
+
click.echo("\n๐ก Fix the critical issues above to proceed.")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _output_github_actions(result):
|
|
654
|
+
"""Output results for GitHub Actions."""
|
|
655
|
+
if result.success:
|
|
656
|
+
click.echo("โ
Data contract validation passed")
|
|
657
|
+
click.echo(f"::notice::Validation successful - {result.summary}")
|
|
658
|
+
else:
|
|
659
|
+
click.echo("โ Data contract validation failed")
|
|
660
|
+
click.echo(f"::error::Validation failed - {result.summary}")
|
|
661
|
+
|
|
662
|
+
for issue in result.critical_issues:
|
|
663
|
+
click.echo(f"::error::{issue.table}.{issue.column}: {issue.message}")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def main():
|
|
667
|
+
"""Main entry point."""
|
|
668
|
+
cli()
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
if __name__ == "__main__":
|
|
672
|
+
main()
|
|
File without changes
|