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.
@@ -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 ()