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.
@@ -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"
@@ -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