sqlassert-dbt 0.3.1__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.
- sqlassert_dbt/__init__.py +19 -0
- sqlassert_dbt/__main__.py +115 -0
- sqlassert_dbt/expectations.py +336 -0
- sqlassert_dbt/jinja.py +183 -0
- sqlassert_dbt/manifest.py +254 -0
- sqlassert_dbt/program.py +686 -0
- sqlassert_dbt/project.py +340 -0
- sqlassert_dbt/report.py +158 -0
- sqlassert_dbt-0.3.1.dist-info/METADATA +153 -0
- sqlassert_dbt-0.3.1.dist-info/RECORD +14 -0
- sqlassert_dbt-0.3.1.dist-info/WHEEL +5 -0
- sqlassert_dbt-0.3.1.dist-info/entry_points.txt +2 -0
- sqlassert_dbt-0.3.1.dist-info/licenses/LICENSE +21 -0
- sqlassert_dbt-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Read the parts of a dbt `manifest.json` that analysis needs.
|
|
2
|
+
|
|
3
|
+
Only the latest manifest schema is read (`SCHEMA`, written by dbt 1.8 through
|
|
4
|
+
1.12); any other is a `ManifestError`, rather than a different shape read as
|
|
5
|
+
if it were this one. Only plain values leave this module: no dbt import, no
|
|
6
|
+
Jinja. A field the manifest lacks reads as absent, never as a guess; a
|
|
7
|
+
manifest that is not a JSON object of nodes is a `ManifestError`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# The manifest schema this module reads: the latest dbt writes.
|
|
20
|
+
SCHEMA = "https://schemas.getdbt.com/dbt/manifest/v12.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ManifestError(Exception):
|
|
24
|
+
"""The manifest is missing, unreadable, or not shaped like a dbt manifest."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Constraint:
|
|
29
|
+
"""One model contract constraint, column-level (`columns` is that one
|
|
30
|
+
column) or model-level. `to` is a `ref(...)`/`source(...)` call as written,
|
|
31
|
+
`expression` the raw SQL of an older-style foreign key."""
|
|
32
|
+
|
|
33
|
+
type: str
|
|
34
|
+
columns: tuple[str, ...] = ()
|
|
35
|
+
expression: str | None = None
|
|
36
|
+
to: str | None = None
|
|
37
|
+
to_columns: tuple[str, ...] = ()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Column:
|
|
42
|
+
name: str
|
|
43
|
+
data_type: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class InjectedCte:
|
|
48
|
+
"""An ephemeral model dbt inlined into a node's compiled code: `sql` is
|
|
49
|
+
the exact text it inserted (` __dbt__cte__name as (\\n...\\n)`)."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
sql: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Relation:
|
|
57
|
+
"""A model, seed, snapshot, or source: anything a ref() or source() names."""
|
|
58
|
+
|
|
59
|
+
unique_id: str
|
|
60
|
+
resource_type: str
|
|
61
|
+
name: str
|
|
62
|
+
package_name: str
|
|
63
|
+
relation_name: str | None
|
|
64
|
+
original_file_path: str
|
|
65
|
+
patch_path: str | None = None
|
|
66
|
+
materialized: str | None = None
|
|
67
|
+
language: str = "sql"
|
|
68
|
+
raw_code: str = ""
|
|
69
|
+
compiled_code: str | None = None
|
|
70
|
+
compiled_path: str | None = None
|
|
71
|
+
depends_on: tuple[str, ...] = ()
|
|
72
|
+
columns: tuple[Column, ...] = ()
|
|
73
|
+
constraints: tuple[Constraint, ...] = ()
|
|
74
|
+
contract_enforced: bool = False
|
|
75
|
+
incremental_strategy: str | None = None
|
|
76
|
+
unique_key: tuple[str, ...] = ()
|
|
77
|
+
# Whether `incremental_predicates` narrow which existing rows the merge matches.
|
|
78
|
+
incremental_predicates: bool = False
|
|
79
|
+
column_types: Mapping[str, str] = field(default_factory=dict)
|
|
80
|
+
extra_ctes: tuple[InjectedCte, ...] = ()
|
|
81
|
+
source_name: str | None = None
|
|
82
|
+
version: str | None = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def label(self) -> str:
|
|
86
|
+
return f"{self.source_name}.{self.name}" if self.resource_type == "source" else self.name
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Test:
|
|
91
|
+
"""A data test. `test_name` and `kwargs` come from `test_metadata` and are
|
|
92
|
+
empty for a singular test."""
|
|
93
|
+
|
|
94
|
+
unique_id: str
|
|
95
|
+
name: str
|
|
96
|
+
original_file_path: str
|
|
97
|
+
test_name: str | None = None
|
|
98
|
+
namespace: str | None = None
|
|
99
|
+
kwargs: Mapping[str, Any] = field(default_factory=dict)
|
|
100
|
+
column_name: str | None = None
|
|
101
|
+
attached_node: str | None = None
|
|
102
|
+
depends_on: tuple[str, ...] = ()
|
|
103
|
+
severity: str = "error"
|
|
104
|
+
where: str | None = None
|
|
105
|
+
fail_calc: str = "count(*)"
|
|
106
|
+
warn_if: str = "!= 0"
|
|
107
|
+
error_if: str = "!= 0"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Manifest:
|
|
112
|
+
adapter_type: str | None
|
|
113
|
+
project_name: str | None
|
|
114
|
+
schema_version: str | None
|
|
115
|
+
relations: Mapping[str, Relation]
|
|
116
|
+
tests: Mapping[str, Test]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_manifest(path: Path) -> Manifest:
|
|
120
|
+
try:
|
|
121
|
+
document = json.loads(path.read_text())
|
|
122
|
+
except OSError as error:
|
|
123
|
+
raise ManifestError(f"cannot read {path}: {error.strerror or error}") from error
|
|
124
|
+
except ValueError as error:
|
|
125
|
+
raise ManifestError(f"{path} is not JSON: {error}") from error
|
|
126
|
+
if not isinstance(document, dict) or not isinstance(document.get("nodes"), dict):
|
|
127
|
+
raise ManifestError(f"{path} is not a dbt manifest: it has no nodes")
|
|
128
|
+
metadata = _mapping(document.get("metadata"))
|
|
129
|
+
schema = _text(metadata.get("dbt_schema_version"))
|
|
130
|
+
if schema != SCHEMA:
|
|
131
|
+
raise ManifestError(
|
|
132
|
+
f"{path} has manifest schema {schema or 'unknown'}; sqlassert-dbt reads only the latest, {SCHEMA}: "
|
|
133
|
+
"compile with a current dbt"
|
|
134
|
+
)
|
|
135
|
+
relations: dict[str, Relation] = {}
|
|
136
|
+
tests: dict[str, Test] = {}
|
|
137
|
+
for unique_id, node in document["nodes"].items():
|
|
138
|
+
if not isinstance(node, dict):
|
|
139
|
+
continue
|
|
140
|
+
match node.get("resource_type"):
|
|
141
|
+
case "model" | "seed" | "snapshot":
|
|
142
|
+
relations[unique_id] = _relation(unique_id, node)
|
|
143
|
+
case "test":
|
|
144
|
+
tests[unique_id] = _test(unique_id, node)
|
|
145
|
+
case _:
|
|
146
|
+
pass # Analyses, operations and unit tests name no relation and state no Property.
|
|
147
|
+
for unique_id, node in _mapping(document.get("sources")).items():
|
|
148
|
+
if isinstance(node, dict):
|
|
149
|
+
relations[unique_id] = _relation(unique_id, node)
|
|
150
|
+
return Manifest(
|
|
151
|
+
adapter_type=_text(metadata.get("adapter_type")),
|
|
152
|
+
project_name=_text(metadata.get("project_name")),
|
|
153
|
+
schema_version=schema,
|
|
154
|
+
relations=relations,
|
|
155
|
+
tests=tests,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _relation(unique_id: str, node: dict) -> Relation:
|
|
160
|
+
config = _mapping(node.get("config"))
|
|
161
|
+
columns = tuple(
|
|
162
|
+
Column(name=str(column.get("name") or name), data_type=_text(column.get("data_type")))
|
|
163
|
+
for name, column in _mapping(node.get("columns")).items()
|
|
164
|
+
if isinstance(column, dict)
|
|
165
|
+
)
|
|
166
|
+
column_constraints = tuple(
|
|
167
|
+
_constraint(constraint, (str(column.get("name") or name),))
|
|
168
|
+
for name, column in _mapping(node.get("columns")).items()
|
|
169
|
+
if isinstance(column, dict)
|
|
170
|
+
for constraint in column.get("constraints") or ()
|
|
171
|
+
if isinstance(constraint, dict)
|
|
172
|
+
)
|
|
173
|
+
model_constraints = tuple(
|
|
174
|
+
_constraint(constraint, ()) for constraint in node.get("constraints") or () if isinstance(constraint, dict)
|
|
175
|
+
)
|
|
176
|
+
contract = _mapping(node.get("contract")) or _mapping(config.get("contract"))
|
|
177
|
+
version = node.get("version")
|
|
178
|
+
return Relation(
|
|
179
|
+
unique_id=unique_id,
|
|
180
|
+
resource_type=str(node.get("resource_type")),
|
|
181
|
+
name=str(node.get("name")),
|
|
182
|
+
package_name=str(node.get("package_name")),
|
|
183
|
+
relation_name=_text(node.get("relation_name")),
|
|
184
|
+
original_file_path=str(node.get("original_file_path") or ""),
|
|
185
|
+
# `package://models/schema.yml`: the file a model's columns and tests are written in.
|
|
186
|
+
patch_path=(_text(node.get("patch_path")) or "").partition("://")[2] or None,
|
|
187
|
+
materialized=_text(config.get("materialized")),
|
|
188
|
+
language=str(node.get("language") or "sql"),
|
|
189
|
+
raw_code=str(node.get("raw_code") or node.get("raw_sql") or ""),
|
|
190
|
+
compiled_code=_text(node.get("compiled_code")),
|
|
191
|
+
compiled_path=_text(node.get("compiled_path")),
|
|
192
|
+
depends_on=tuple(_mapping(node.get("depends_on")).get("nodes") or ()),
|
|
193
|
+
columns=columns,
|
|
194
|
+
constraints=column_constraints + model_constraints,
|
|
195
|
+
contract_enforced=bool(contract.get("enforced")),
|
|
196
|
+
incremental_strategy=_text(config.get("incremental_strategy")),
|
|
197
|
+
unique_key=_names(config.get("unique_key")),
|
|
198
|
+
incremental_predicates=bool(config.get("incremental_predicates")),
|
|
199
|
+
column_types={str(key): str(value) for key, value in _mapping(config.get("column_types")).items()},
|
|
200
|
+
extra_ctes=tuple(
|
|
201
|
+
InjectedCte(id=str(cte.get("id")), sql=str(cte.get("sql")))
|
|
202
|
+
for cte in node.get("extra_ctes") or ()
|
|
203
|
+
if isinstance(cte, dict) and cte.get("sql") is not None
|
|
204
|
+
),
|
|
205
|
+
source_name=_text(node.get("source_name")),
|
|
206
|
+
version=None if version is None else str(version),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _constraint(constraint: dict, columns: tuple[str, ...]) -> Constraint:
|
|
211
|
+
return Constraint(
|
|
212
|
+
type=str(constraint.get("type")),
|
|
213
|
+
columns=columns or _names(constraint.get("columns")),
|
|
214
|
+
expression=_text(constraint.get("expression")),
|
|
215
|
+
to=_text(constraint.get("to")),
|
|
216
|
+
to_columns=_names(constraint.get("to_columns")),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _test(unique_id: str, node: dict) -> Test:
|
|
221
|
+
config = _mapping(node.get("config"))
|
|
222
|
+
metadata = _mapping(node.get("test_metadata"))
|
|
223
|
+
return Test(
|
|
224
|
+
unique_id=unique_id,
|
|
225
|
+
name=str(node.get("name")),
|
|
226
|
+
original_file_path=str(node.get("original_file_path") or ""),
|
|
227
|
+
test_name=_text(metadata.get("name")),
|
|
228
|
+
namespace=_text(metadata.get("namespace")),
|
|
229
|
+
kwargs=_mapping(metadata.get("kwargs")),
|
|
230
|
+
column_name=_text(node.get("column_name")),
|
|
231
|
+
attached_node=_text(node.get("attached_node")),
|
|
232
|
+
depends_on=tuple(_mapping(node.get("depends_on")).get("nodes") or ()),
|
|
233
|
+
severity=str(config.get("severity") or "error").lower(),
|
|
234
|
+
where=_text(config.get("where")),
|
|
235
|
+
fail_calc=str(config.get("fail_calc") or "count(*)"),
|
|
236
|
+
warn_if=str(config.get("warn_if") or "!= 0"),
|
|
237
|
+
error_if=str(config.get("error_if") or "!= 0"),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _mapping(value: object) -> dict:
|
|
242
|
+
return value if isinstance(value, dict) else {}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _text(value: object) -> str | None:
|
|
246
|
+
return value if isinstance(value, str) and value else None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _names(value: object) -> tuple[str, ...]:
|
|
250
|
+
if isinstance(value, str):
|
|
251
|
+
return (value,)
|
|
252
|
+
if isinstance(value, list):
|
|
253
|
+
return tuple(str(item) for item in value)
|
|
254
|
+
return ()
|