dbt-preflight 0.2.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.
- dbt_preflight/__init__.py +8 -0
- dbt_preflight/checks.py +238 -0
- dbt_preflight/cli.py +593 -0
- dbt_preflight/config.py +212 -0
- dbt_preflight/conventions.py +159 -0
- dbt_preflight/dbt_runner.py +311 -0
- dbt_preflight/diff.py +543 -0
- dbt_preflight/fixtures.py +187 -0
- dbt_preflight/git.py +93 -0
- dbt_preflight/github.py +85 -0
- dbt_preflight/manifest.py +332 -0
- dbt_preflight/metrics.py +511 -0
- dbt_preflight/report.py +747 -0
- dbt_preflight/schema.py +741 -0
- dbt_preflight/summary.py +191 -0
- dbt_preflight/transpile.py +177 -0
- dbt_preflight-0.2.0.dist-info/METADATA +356 -0
- dbt_preflight-0.2.0.dist-info/RECORD +22 -0
- dbt_preflight-0.2.0.dist-info/WHEEL +5 -0
- dbt_preflight-0.2.0.dist-info/entry_points.txt +2 -0
- dbt_preflight-0.2.0.dist-info/licenses/LICENSE +21 -0
- dbt_preflight-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""dbt preflight: warehouse-free CI for dbt pull requests.
|
|
2
|
+
|
|
3
|
+
Builds and tests the models a pull request changes against synthetic data generated by
|
|
4
|
+
model2data, checks house conventions, and produces one review comment. Nothing here needs
|
|
5
|
+
a warehouse credential.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
dbt_preflight/checks.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""House conventions, checked against the manifest and the built tables.
|
|
2
|
+
|
|
3
|
+
The rules are the JB Analytica warehouse conventions: staging / intermediate / marts
|
|
4
|
+
layering with the matching name prefixes, one source per staging model, every model
|
|
5
|
+
described, a tested primary key, snake_case columns, and type-revealing suffixes
|
|
6
|
+
(`_at`, `_date`, `is_` / `has_`). Column-type rules run against the DuckDB tables the
|
|
7
|
+
build produced, so they see the real output types rather than a YAML claim.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import duckdb
|
|
16
|
+
|
|
17
|
+
from dbt_preflight.conventions import ERROR, WARN, ConventionSet, jba
|
|
18
|
+
from dbt_preflight.manifest import Manifest, ModelNode
|
|
19
|
+
|
|
20
|
+
SEVERITY_ERROR = ERROR
|
|
21
|
+
SEVERITY_WARN = WARN
|
|
22
|
+
|
|
23
|
+
_SNAKE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Violation:
|
|
28
|
+
rule: str
|
|
29
|
+
severity: str
|
|
30
|
+
model: str
|
|
31
|
+
path: str # repo-relative
|
|
32
|
+
message: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def relation(model: ModelNode) -> str:
|
|
36
|
+
"""The fully qualified, quoted DuckDB name of a model's table.
|
|
37
|
+
|
|
38
|
+
Always three parts: when the catalog (the DuckDB file) and the schema share a name,
|
|
39
|
+
a two-part name is ambiguous to DuckDB.
|
|
40
|
+
"""
|
|
41
|
+
parts = [model.database, model.schema, model.alias]
|
|
42
|
+
return ".".join(f'"{p}"' for p in parts if p)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _path(model: ModelNode, project_relpath: str, yaml: bool = False) -> str:
|
|
46
|
+
"""Repo-relative path of the model's SQL, or of its YAML when the fix belongs there."""
|
|
47
|
+
rel = (model.patch_path if yaml and model.patch_path else None) or model.original_file_path
|
|
48
|
+
return f"{project_relpath}/{rel}" if project_relpath not in {"", "."} else rel
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_manifest(
|
|
52
|
+
manifest: Manifest,
|
|
53
|
+
model_ids: list[str],
|
|
54
|
+
project_relpath: str,
|
|
55
|
+
conventions: ConventionSet | None = None,
|
|
56
|
+
) -> list[Violation]:
|
|
57
|
+
c = conventions or jba()
|
|
58
|
+
out: list[Violation] = []
|
|
59
|
+
for uid in model_ids:
|
|
60
|
+
model = manifest.models.get(uid)
|
|
61
|
+
if model is None:
|
|
62
|
+
continue
|
|
63
|
+
path = _path(model, project_relpath)
|
|
64
|
+
layer = model.layer
|
|
65
|
+
|
|
66
|
+
pattern = c.pattern(layer)
|
|
67
|
+
if c.enabled("naming") and pattern and not pattern.match(model.name):
|
|
68
|
+
out.append(
|
|
69
|
+
Violation(
|
|
70
|
+
"naming",
|
|
71
|
+
c.severity("naming"),
|
|
72
|
+
model.name,
|
|
73
|
+
path,
|
|
74
|
+
f"models in `{layer}/` are named {c.hints.get(layer, 'differently')}",
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if c.enabled("layering"):
|
|
79
|
+
sources = [d for d in model.depends_on if d.startswith("source.")]
|
|
80
|
+
models = [d for d in model.depends_on if d.startswith("model.")]
|
|
81
|
+
sev = c.severity("layering")
|
|
82
|
+
if c.source_layer and layer == c.source_layer:
|
|
83
|
+
if len(sources) != 1:
|
|
84
|
+
out.append(
|
|
85
|
+
Violation(
|
|
86
|
+
"layering",
|
|
87
|
+
sev,
|
|
88
|
+
model.name,
|
|
89
|
+
path,
|
|
90
|
+
f"a {layer} model reads exactly one source; this one reads {len(sources)}",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
if models:
|
|
94
|
+
out.append(
|
|
95
|
+
Violation(
|
|
96
|
+
"layering",
|
|
97
|
+
sev,
|
|
98
|
+
model.name,
|
|
99
|
+
path,
|
|
100
|
+
f"a {layer} model does not `ref()` other models; joins belong downstream",
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
elif c.source_layer and layer and sources:
|
|
104
|
+
out.append(
|
|
105
|
+
Violation(
|
|
106
|
+
"layering",
|
|
107
|
+
sev,
|
|
108
|
+
model.name,
|
|
109
|
+
path,
|
|
110
|
+
f"only `{c.source_layer}/` reads `source()`; go through a "
|
|
111
|
+
f"{c.source_layer} model instead "
|
|
112
|
+
f"({', '.join(s.split('.')[-1] for s in sources)})",
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
yaml_path = _path(model, project_relpath, yaml=True)
|
|
117
|
+
if c.enabled("description") and not model.description.strip():
|
|
118
|
+
where = "in its YAML" if model.patch_path else "in a YAML entry (none exists yet)"
|
|
119
|
+
out.append(
|
|
120
|
+
Violation(
|
|
121
|
+
"description",
|
|
122
|
+
c.severity("description"),
|
|
123
|
+
model.name,
|
|
124
|
+
yaml_path,
|
|
125
|
+
f"`{model.name}` has no description; add one {where}",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if c.enabled("primary_key"):
|
|
130
|
+
tests = manifest.column_tests(uid)
|
|
131
|
+
has_pk = any({"unique", "not_null"} <= names for names in tests.values())
|
|
132
|
+
if not has_pk:
|
|
133
|
+
out.append(
|
|
134
|
+
Violation(
|
|
135
|
+
"primary_key",
|
|
136
|
+
c.severity("primary_key"),
|
|
137
|
+
model.name,
|
|
138
|
+
yaml_path,
|
|
139
|
+
f"`{model.name}` has no column tested `unique` + `not_null`; "
|
|
140
|
+
"test its primary key",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_columns(
|
|
147
|
+
manifest: Manifest,
|
|
148
|
+
model_ids: list[str],
|
|
149
|
+
project_relpath: str,
|
|
150
|
+
db_path,
|
|
151
|
+
conventions: ConventionSet | None = None,
|
|
152
|
+
) -> list[Violation]:
|
|
153
|
+
"""Column naming rules, read from the tables the build produced."""
|
|
154
|
+
c = conventions or jba()
|
|
155
|
+
if not c.enabled("column_naming"):
|
|
156
|
+
return []
|
|
157
|
+
sev = c.severity("column_naming")
|
|
158
|
+
out: list[Violation] = []
|
|
159
|
+
con = duckdb.connect(str(db_path))
|
|
160
|
+
try:
|
|
161
|
+
for uid in model_ids:
|
|
162
|
+
model = manifest.models.get(uid)
|
|
163
|
+
# Only models inside a declared layer; utilities and the like are exempt.
|
|
164
|
+
if model is None or (c.layers and model.layer not in c.layers):
|
|
165
|
+
continue
|
|
166
|
+
path = _path(model, project_relpath)
|
|
167
|
+
rows = con.execute(
|
|
168
|
+
"select column_name, data_type from information_schema.columns "
|
|
169
|
+
"where table_catalog = coalesce(?, table_catalog) "
|
|
170
|
+
"and table_schema = ? and table_name = ?",
|
|
171
|
+
[model.database, model.schema, model.alias],
|
|
172
|
+
).fetchall()
|
|
173
|
+
for column, data_type in rows:
|
|
174
|
+
dtype = str(data_type).upper()
|
|
175
|
+
if not _SNAKE.match(column):
|
|
176
|
+
out.append(
|
|
177
|
+
Violation(
|
|
178
|
+
"column_naming",
|
|
179
|
+
sev,
|
|
180
|
+
model.name,
|
|
181
|
+
path,
|
|
182
|
+
f"column `{column}` is not snake_case",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
continue
|
|
186
|
+
if dtype.startswith("TIMESTAMP") and not column.endswith(c.timestamp_suffix):
|
|
187
|
+
out.append(
|
|
188
|
+
Violation(
|
|
189
|
+
"column_naming",
|
|
190
|
+
sev,
|
|
191
|
+
model.name,
|
|
192
|
+
path,
|
|
193
|
+
f"timestamp column `{column}` should end in `{c.timestamp_suffix}`",
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
elif dtype == "DATE" and not column.endswith(c.date_suffix):
|
|
197
|
+
out.append(
|
|
198
|
+
Violation(
|
|
199
|
+
"column_naming",
|
|
200
|
+
sev,
|
|
201
|
+
model.name,
|
|
202
|
+
path,
|
|
203
|
+
f"date column `{column}` should end in `{c.date_suffix}`",
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
elif dtype == "BOOLEAN" and not column.startswith(c.boolean_prefixes):
|
|
207
|
+
prefixes = " or ".join(f"`{p}`" for p in c.boolean_prefixes)
|
|
208
|
+
out.append(
|
|
209
|
+
Violation(
|
|
210
|
+
"column_naming",
|
|
211
|
+
sev,
|
|
212
|
+
model.name,
|
|
213
|
+
path,
|
|
214
|
+
f"boolean column `{column}` should read as a claim: {prefixes}",
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
finally:
|
|
218
|
+
con.close()
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def row_counts(manifest: Manifest, model_ids: list[str], db_path) -> dict[str, int]:
|
|
223
|
+
counts: dict[str, int] = {}
|
|
224
|
+
con = duckdb.connect(str(db_path))
|
|
225
|
+
try:
|
|
226
|
+
for uid in model_ids:
|
|
227
|
+
model = manifest.models.get(uid)
|
|
228
|
+
if model is None:
|
|
229
|
+
continue
|
|
230
|
+
try:
|
|
231
|
+
row = con.execute(f"select count(*) from {relation(model)}").fetchone()
|
|
232
|
+
except duckdb.Error:
|
|
233
|
+
continue
|
|
234
|
+
if row:
|
|
235
|
+
counts[uid] = int(row[0])
|
|
236
|
+
finally:
|
|
237
|
+
con.close()
|
|
238
|
+
return counts
|