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,19 @@
1
+ """dbt integration: check a dbt project's tests and Property Markers from its manifest, without dbt.
2
+
3
+ `analyze_project(project_dir)` reads `target/manifest.json` and returns a
4
+ `DbtReport`: which tests the SQL proves (skippable), which it does not, which
5
+ proved Properties no test states, and the outcome of every marker written in
6
+ a model file, mapped back to that file's lines. `sqlassert-dbt` is its command
7
+ line (`__main__.py`).
8
+ """
9
+
10
+ from sqlassert_dbt.project import SUPPORTED_DIALECTS, ProjectError, analyze_project
11
+ from sqlassert_dbt.report import (
12
+ DbtReport, Location, MarkerOutcome, Problem, Status, TestOutcome, Untested,
13
+ exit_code, format_text,
14
+ )
15
+
16
+ __all__ = [
17
+ "SUPPORTED_DIALECTS", "DbtReport", "Location", "MarkerOutcome", "Problem",
18
+ "ProjectError", "Status", "TestOutcome", "Untested", "analyze_project", "exit_code", "format_text",
19
+ ]
@@ -0,0 +1,115 @@
1
+ """Command line.
2
+
3
+ `sqlassert-dbt check [PROJECT]` checks a compiled dbt project's tests and
4
+ markers from its `target/manifest.json`, with `sqlassert check`'s exit codes.
5
+
6
+ `sqlassert-dbt build|test [PROJECT] [-- DBT_ARGS]` runs dbt with the markers
7
+ checked too: `dbt compile`, then the check, then `dbt build` (or `dbt test`)
8
+ with every test the SQL proves excluded. dbt runs the tests of what a model
9
+ reads in the same invocation, so they are facts, unless DBT_ARGS select nodes
10
+ (a selection may leave an upstream test out).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import subprocess
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from sqlassert.check import EXIT_FINDINGS, EXIT_OK, EXIT_USAGE
21
+ from sqlassert_dbt.project import ProjectError, analyze_project
22
+ from sqlassert_dbt.report import DbtReport, exit_code, format_text
23
+
24
+ # The dbt flags that narrow which nodes run: under any of them, the tests of
25
+ # what a model reads may not run, so they are no facts.
26
+ SELECTION_FLAGS = frozenset({"-s", "--select", "-m", "--models", "--model", "--exclude", "--selector", "--resource-type", "--resource-types"})
27
+
28
+
29
+ def run_dbt(arguments: list[str]) -> int:
30
+ """Run dbt from PATH with `arguments`; its exit status."""
31
+ try:
32
+ return subprocess.run(["dbt", *arguments], check=False).returncode
33
+ except FileNotFoundError:
34
+ print("sqlassert-dbt: dbt is not on PATH", file=sys.stderr)
35
+ return EXIT_USAGE
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ parser = argparse.ArgumentParser(prog="sqlassert-dbt")
40
+ commands = parser.add_subparsers(dest="command", required=True)
41
+ check = commands.add_parser(
42
+ "check",
43
+ help="check the tests and markers of a compiled project, without dbt",
44
+ description="Check a dbt project's tests and markers from its target/manifest.json (run `dbt compile` first). "
45
+ f"Exit status: {EXIT_OK} every marker PROVED, {EXIT_FINDINGS} a marker UNKNOWN or a diagnostic reported, "
46
+ f"{EXIT_USAGE} the manifest cannot be read.",
47
+ )
48
+ _common(check)
49
+ check.add_argument("--manifest", help="manifest.json to read (default: PROJECT/target/manifest.json)")
50
+ check.add_argument(
51
+ "--trust-upstream-tests", action="store_true",
52
+ help="take the passing tests of what each model reads as facts about it: true when they run first, as in `dbt build`",
53
+ )
54
+ for name, verb in (("build", "dbt build"), ("test", "dbt test")):
55
+ command = commands.add_parser(
56
+ name,
57
+ help=f"`dbt compile`, check the markers, then `{verb}` without the tests the SQL proves",
58
+ description=f"Run `dbt compile`, check the project, then `{verb}` excluding every test the SQL proves. "
59
+ f"Arguments after -- go to dbt. Exit status: dbt's when it fails, else {EXIT_FINDINGS} when a marker is "
60
+ f"UNKNOWN or a diagnostic is reported, else {EXIT_OK}.",
61
+ )
62
+ _common(command)
63
+ command.add_argument("dbt_arguments", nargs=argparse.REMAINDER, help="-- then arguments for dbt")
64
+ arguments = parser.parse_args(argv)
65
+ project = Path(arguments.project)
66
+
67
+ if arguments.command == "check":
68
+ report = _analyze(project, arguments.manifest, arguments.dialect, arguments.trust_upstream_tests)
69
+ if report is None:
70
+ return EXIT_USAGE
71
+ sys.stdout.write(format_text(report, explain=arguments.explain))
72
+ return exit_code(report, require_proved=arguments.require_proved)
73
+
74
+ passed = [argument for argument in arguments.dbt_arguments if argument != "--"]
75
+ located = [*passed, "--project-dir", str(project)]
76
+ compiled = run_dbt(["compile", *located])
77
+ if compiled != 0:
78
+ return compiled
79
+ selecting = any(argument.split("=")[0] in SELECTION_FLAGS for argument in passed)
80
+ report = _analyze(project, None, arguments.dialect, not selecting)
81
+ if report is None:
82
+ return EXIT_USAGE
83
+ sys.stdout.write(format_text(report, explain=arguments.explain))
84
+ proved = proved_tests(report)
85
+ ran = run_dbt([arguments.command, *located, *(["--exclude", *proved] if proved else [])])
86
+ return ran if ran != 0 else exit_code(report, require_proved=arguments.require_proved)
87
+
88
+
89
+ def _common(command: argparse.ArgumentParser) -> None:
90
+ command.add_argument("project", nargs="?", default=".", help="dbt project directory (default: .)")
91
+ command.add_argument("--dialect", help="analyze under this dialect instead of the adapter's")
92
+ command.add_argument("--explain", action="store_true", help="print each proof and failed proof")
93
+ command.add_argument(
94
+ "--require-proved", action="store_true",
95
+ help=f"exit {EXIT_FINDINGS} when a dbt test is not proved from the SQL, too, not only a marker",
96
+ )
97
+
98
+
99
+ def proved_tests(report: DbtReport) -> list[str]:
100
+ """The names of the tests the SQL proves, to pass dbt as `--exclude`."""
101
+ return sorted({test.name for test in report.tests if test.skippable})
102
+
103
+
104
+ def _analyze(project: Path, manifest: str | None, dialect: str | None, trust: bool) -> DbtReport | None:
105
+ try:
106
+ return analyze_project(
107
+ project, manifest_path=Path(manifest) if manifest else None, dialect=dialect, trust_upstream_tests=trust,
108
+ )
109
+ except ProjectError as error:
110
+ print(f"sqlassert-dbt: {error}", file=sys.stderr)
111
+ return None
112
+
113
+
114
+ if __name__ == "__main__":
115
+ sys.exit(main())
@@ -0,0 +1,336 @@
1
+ """Map dbt data tests and model contract constraints onto Properties.
2
+
3
+ An Expectation is one test or constraint read as a Property of the relation it
4
+ is attached to. It is used twice: as an Assertion about its own model (can
5
+ analysis prove the test passes?) and, for every model downstream, as a
6
+ Property Declaration (the test ran and passed before they were built). The
7
+ two can differ: `dbt_utils.unique_combination_of_columns` counts NULL equal
8
+ to NULL, so it is proved by a Non-Null Unique Set but declares only a Unique
9
+ Set; a test with a `where:` config is proved by the Property on every row but
10
+ declares nothing, since it checked only some rows.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import re
17
+ from collections.abc import Iterator, Mapping
18
+ from dataclasses import dataclass, replace
19
+ from typing import Literal
20
+
21
+ import sqlglot
22
+ from sqlglot.errors import SqlglotError
23
+
24
+ from sqlassert_dbt.manifest import Constraint, Manifest, Relation, Test
25
+ from sqlassert.ir.model import LiteralValue
26
+ from sqlassert.sql_parse import literal_value
27
+
28
+ # How an Expectation enters analysis: "unique" a Unique Set, "key" a Non-Null
29
+ # Unique Set, "not_null" Not Null, "enum" an Enumerated Domain, "coverage"
30
+ # Referential Coverage (a dbt relationships test checks no Unique Set on its target).
31
+ type PropertyKind = Literal["unique", "key", "not_null", "enum", "coverage"]
32
+
33
+ # Constraint types each adapter enforces when a contracted table is built
34
+ # (dbt's per-adapter `CONSTRAINT_SUPPORT`). Every other adapter enforces
35
+ # NOT NULL only; their keys are informational. Constraints never apply to views.
36
+ _ENFORCED_CONSTRAINTS = {
37
+ "postgres": frozenset({"not_null", "unique", "primary_key", "foreign_key"}),
38
+ "duckdb": frozenset({"not_null", "unique", "primary_key", "foreign_key"}),
39
+ }
40
+ _NOT_NULL_ONLY = frozenset({"not_null"})
41
+ _CONSTRAINED_MATERIALIZATIONS = {"table", "incremental"}
42
+
43
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
44
+ _QUOTED = re.compile(r'"([^"]+)"|`([^`]+)`|\[([^\]]+)\]')
45
+ _CALL = re.compile(r"\b(ref|source)\s*\(.*?\)", re.DOTALL)
46
+ _THRESHOLD = re.compile(r"\s*(!=|<>|>=|<=|==|=|>|<)\s*(\d+)\s*")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Expectation:
51
+ """One test or constraint as a Property of relation `node`.
52
+
53
+ `asserted` is the Property whose proof proves it passes, None when none
54
+ does (`unprovable` says why). `declared` is the Property it guarantees
55
+ downstream once it passed, None when it is no fact (`not_a_fact` says why).
56
+ """
57
+
58
+ unique_id: str
59
+ name: str
60
+ kind: Literal["test", "constraint"]
61
+ node: str
62
+ path: str
63
+ asserted: PropertyKind | None
64
+ declared: PropertyKind | None
65
+ columns: tuple[str, ...] = ()
66
+ values: tuple[LiteralValue, ...] = ()
67
+ referenced: str | None = None
68
+ referenced_columns: tuple[str, ...] = ()
69
+ unprovable: str | None = None
70
+ not_a_fact: str | None = None
71
+
72
+ @property
73
+ def is_fact(self) -> bool:
74
+ return self.declared is not None and self.not_a_fact is None
75
+
76
+
77
+ def expectations(manifest: Manifest) -> Iterator[Expectation]:
78
+ for test in manifest.tests.values():
79
+ expectation = _test(test, manifest.relations)
80
+ if expectation is not None:
81
+ yield expectation
82
+ for relation in manifest.relations.values():
83
+ yield from _constraints(relation, manifest)
84
+
85
+
86
+ def _test(test: Test, relations: Mapping[str, Relation]) -> Expectation | None:
87
+ node = _attached(test, relations)
88
+ if node is None:
89
+ return None # A test of nothing analysis knows: a seed-less singular test, say.
90
+ base = Expectation(test.unique_id, test.name, "test", node, test.original_file_path, asserted=None, declared=None)
91
+ weakness = _weakness(test)
92
+ unprovable = _threshold_unprovable(test)
93
+ name = test.test_name if test.namespace is None else f"{test.namespace}.{test.test_name}"
94
+ match name:
95
+ case None:
96
+ return replace(base, asserted=None, declared=None, unprovable="a singular test states no Property")
97
+ case "unique" | "not_null" | "accepted_values" | "relationships":
98
+ column = _column(test.kwargs.get("column_name") or test.column_name)
99
+ if column is None:
100
+ return replace(
101
+ base,
102
+ unprovable=f"it tests the expression {test.kwargs.get('column_name') or test.column_name!r}, not a column",
103
+ )
104
+ case "dbt_utils.unique_combination_of_columns":
105
+ names = test.kwargs.get("combination_of_columns")
106
+ columns = tuple(_column(name) for name in names) if isinstance(names, list) and names else (None,)
107
+ if None in columns:
108
+ return replace(base, asserted=None, declared=None, unprovable="its columns are not all plain columns")
109
+ return replace(
110
+ base, asserted="key", declared="unique", columns=tuple(c for c in columns if c is not None),
111
+ unprovable=unprovable, not_a_fact=weakness,
112
+ )
113
+ case _:
114
+ return replace(base, asserted=None, declared=None, unprovable=f"the {name} test maps to no Property")
115
+ match name:
116
+ case "unique":
117
+ return replace(base, asserted="unique", declared="unique", columns=(column,), unprovable=unprovable, not_a_fact=weakness)
118
+ case "not_null":
119
+ return replace(base, asserted="not_null", declared="not_null", columns=(column,), unprovable=unprovable, not_a_fact=weakness)
120
+ case "accepted_values":
121
+ values = _accepted_values(test.kwargs.get("values"), test.kwargs.get("quote", True))
122
+ if values is None:
123
+ return replace(base, asserted=None, declared=None, unprovable="its values are not all string, numeric or boolean literals")
124
+ return replace(
125
+ base, asserted="enum", declared="enum", columns=(column,), values=values, unprovable=unprovable, not_a_fact=weakness,
126
+ )
127
+ case _:
128
+ target = _resolve_call(test.kwargs.get("to"), test.depends_on, relations)
129
+ field = _column(test.kwargs.get("field"))
130
+ if target is None or field is None:
131
+ return replace(base, asserted=None, declared=None, unprovable="its `to` or `field` cannot be resolved")
132
+ return replace(
133
+ base, asserted="coverage", declared="coverage", columns=(column,), referenced=target, referenced_columns=(field,),
134
+ unprovable=unprovable, not_a_fact=weakness,
135
+ )
136
+
137
+
138
+ def _attached(test: Test, relations: Mapping[str, Relation]) -> str | None:
139
+ if test.attached_node in relations:
140
+ return test.attached_node
141
+ # A source test has no attached node; its model argument names the source.
142
+ attached = _resolve_call(test.kwargs.get("model"), test.depends_on, relations)
143
+ if attached is not None:
144
+ return attached
145
+ candidates = [node for node in test.depends_on if node in relations]
146
+ return candidates[0] if len(candidates) == 1 else None
147
+
148
+
149
+ def _weakness(test: Test) -> str | None:
150
+ """Why a passing test is no fact about every row, or None when it is one."""
151
+ if test.severity != "error":
152
+ return f"its severity is {test.severity}, so a failure does not stop the build"
153
+ if test.where is not None:
154
+ return f"it checks only the rows where {test.where}"
155
+ if _normalized(test.fail_calc) != "count(*)" or not _fails_on_any_failure(test.error_if):
156
+ return f"its thresholds (fail_calc {test.fail_calc}, error_if {test.error_if}) tolerate failing rows"
157
+ return None
158
+
159
+
160
+ def _threshold_unprovable(test: Test) -> str | None:
161
+ """Why proving no row fails does not prove the test passes, or None."""
162
+ if _normalized(test.fail_calc) != "count(*)":
163
+ return f"it computes failures with fail_calc {test.fail_calc}"
164
+ for condition in (test.warn_if, test.error_if):
165
+ if _compare(0, condition) is not False:
166
+ return f"its threshold {condition} is not met by zero failing rows"
167
+ return None
168
+
169
+
170
+ def _fails_on_any_failure(condition: str) -> bool:
171
+ """Whether `condition` errors on one failing row and on every count above it."""
172
+ match = _THRESHOLD.fullmatch(condition)
173
+ return (
174
+ match is not None and match.group(1) in {"!=", "<>", ">", ">="}
175
+ and _compare(0, condition) is False and _compare(1, condition) is True
176
+ )
177
+
178
+
179
+ def _compare(failures: int, condition: str) -> bool | None:
180
+ match = _THRESHOLD.fullmatch(condition)
181
+ if match is None:
182
+ return None
183
+ operator, bound = match.group(1), int(match.group(2))
184
+ return {
185
+ "!=": failures != bound, "<>": failures != bound, ">": failures > bound, ">=": failures >= bound,
186
+ "<": failures < bound, "<=": failures <= bound, "=": failures == bound, "==": failures == bound,
187
+ }[operator]
188
+
189
+
190
+ def _normalized(text: str) -> str:
191
+ return re.sub(r"\s+", "", text).lower()
192
+
193
+
194
+ def _column(name: object) -> str | None:
195
+ """A plain or quoted column name; None for an expression."""
196
+ if not isinstance(name, str):
197
+ return None
198
+ name = name.strip()
199
+ if _IDENTIFIER.fullmatch(name):
200
+ return name
201
+ quoted = _QUOTED.fullmatch(name)
202
+ return next((group for group in quoted.groups() if group), None) if quoted else None
203
+
204
+
205
+ def _accepted_values(values: object, quote: object) -> tuple[LiteralValue, ...] | None:
206
+ """The literals dbt's accepted_values test compares with: each value quoted
207
+ as a string, or with `quote: false`, its text read as a SQL literal."""
208
+ if not isinstance(values, list) or not values:
209
+ return None
210
+ found: list[LiteralValue] = []
211
+ for value in values:
212
+ text = _jinja_text(value)
213
+ if quote is not False:
214
+ literal = LiteralValue(type="string", text=text)
215
+ else:
216
+ try:
217
+ literal = literal_value(sqlglot.parse_one(text))
218
+ except SqlglotError:
219
+ return None
220
+ if literal is None:
221
+ return None
222
+ if literal not in found:
223
+ found.append(literal)
224
+ return tuple(found)
225
+
226
+
227
+ def _jinja_text(value: object) -> str:
228
+ """How Jinja prints a YAML value inside `'{{ value }}'`."""
229
+ return str(value)
230
+
231
+
232
+ def _resolve_call(text: object, depends_on: tuple[str, ...], relations: Mapping[str, Relation]) -> str | None:
233
+ """The relation a `ref(...)` or `source(...)` call written in `text` names,
234
+ looked up among `depends_on` only: dbt resolved it there."""
235
+ if not isinstance(text, str):
236
+ return None
237
+ match = _CALL.search(text)
238
+ if match is None:
239
+ return None
240
+ try:
241
+ call = ast.parse(match.group(0), mode="eval").body
242
+ except SyntaxError:
243
+ return None
244
+ if not isinstance(call, ast.Call) or not all(isinstance(argument, ast.Constant) for argument in call.args):
245
+ return None
246
+ arguments = tuple(str(argument.value) for argument in call.args if isinstance(argument, ast.Constant))
247
+ options: dict[str, object] = {
248
+ keyword.arg: keyword.value.value for keyword in call.keywords
249
+ if keyword.arg is not None and isinstance(keyword.value, ast.Constant)
250
+ }
251
+ return resolve(match.group(1), arguments, options, depends_on, relations)
252
+
253
+
254
+ def resolve(
255
+ function: str, arguments: tuple[str, ...], options: Mapping[str, object], depends_on: tuple[str, ...],
256
+ relations: Mapping[str, Relation],
257
+ ) -> str | None:
258
+ """The unique id among `depends_on` a `ref` or `source` call resolves to, or None."""
259
+ candidates = [relations[node] for node in depends_on if node in relations]
260
+ match function, arguments:
261
+ case "source", (source_name, name):
262
+ found = [r for r in candidates if r.resource_type == "source" and r.source_name == source_name and r.name == name]
263
+ case "ref", (name,) | (_, name):
264
+ package = arguments[0] if len(arguments) == 2 else None
265
+ version = options.get("v", options.get("version"))
266
+ found = [
267
+ r for r in candidates
268
+ if r.resource_type != "source" and r.name == name and (package is None or r.package_name == package)
269
+ and (version is None or r.version == str(version))
270
+ ]
271
+ case _:
272
+ return None
273
+ return found[0].unique_id if len(found) == 1 else None
274
+
275
+
276
+ def _constraints(relation: Relation, manifest: Manifest) -> Iterator[Expectation]:
277
+ enforced = _ENFORCED_CONSTRAINTS.get(manifest.adapter_type or "", _NOT_NULL_ONLY)
278
+ for index, constraint in enumerate(relation.constraints):
279
+ kind: PropertyKind
280
+ match constraint.type:
281
+ case "not_null":
282
+ kind = "not_null"
283
+ case "unique":
284
+ kind = "unique"
285
+ case "primary_key":
286
+ kind = "key"
287
+ case "foreign_key":
288
+ kind = "coverage"
289
+ case _:
290
+ continue # A check or custom constraint states no Property.
291
+ if not relation.contract_enforced:
292
+ not_a_fact = "the model's contract is not enforced"
293
+ elif relation.materialized not in _CONSTRAINED_MATERIALIZATIONS:
294
+ not_a_fact = f"constraints are not applied to a {relation.materialized} materialization"
295
+ elif constraint.type not in enforced:
296
+ not_a_fact = f"{manifest.adapter_type} does not enforce {constraint.type} constraints"
297
+ else:
298
+ not_a_fact = None
299
+ base = Expectation(
300
+ f"{relation.unique_id}.constraint.{index}",
301
+ f"{constraint.type} constraint on {relation.name}({', '.join(constraint.columns)})",
302
+ "constraint", relation.unique_id, relation.patch_path or relation.original_file_path, asserted=None, declared=None,
303
+ )
304
+ if not constraint.columns:
305
+ yield replace(base, asserted=None, declared=None, unprovable="it names no columns")
306
+ continue
307
+ if kind != "coverage":
308
+ yield replace(base, asserted=kind, declared=kind, columns=constraint.columns, not_a_fact=not_a_fact)
309
+ continue
310
+ target, target_columns = _foreign_key_target(constraint, relation, manifest)
311
+ if target is None or len(target_columns) != len(constraint.columns):
312
+ yield replace(base, asserted=None, declared=None, unprovable="its referenced relation or columns cannot be resolved")
313
+ continue
314
+ yield replace(
315
+ base, asserted=kind, declared=kind, columns=constraint.columns, referenced=target, referenced_columns=target_columns,
316
+ not_a_fact=not_a_fact,
317
+ )
318
+
319
+
320
+ def _foreign_key_target(constraint: Constraint, relation: Relation, manifest: Manifest) -> tuple[str | None, tuple[str, ...]]:
321
+ if constraint.to is not None:
322
+ return _resolve_call(constraint.to, relation.depends_on, manifest.relations) or _resolve_call(
323
+ constraint.to, tuple(manifest.relations), manifest.relations
324
+ ), constraint.to_columns
325
+ # An older foreign key's expression is raw SQL: `relation (column, ...)`.
326
+ match = re.fullmatch(r"\s*(.+?)\s*\(([^()]*)\)\s*", constraint.expression or "")
327
+ if match is None:
328
+ return None, ()
329
+ name = _canonical(match.group(1))
330
+ found = [r.unique_id for r in manifest.relations.values() if r.relation_name is not None and _canonical(r.relation_name) == name]
331
+ columns = tuple(column for part in match.group(2).split(",") if (column := _column(part)) is not None)
332
+ return (found[0] if len(found) == 1 else None), columns
333
+
334
+
335
+ def _canonical(name: str) -> str:
336
+ return re.sub(r'["`\[\]\s]', "", name).lower()
sqlassert_dbt/jinja.py ADDED
@@ -0,0 +1,183 @@
1
+ """Render the small Jinja subset most dbt models are written in, without dbt.
2
+
3
+ A model's compiled code is one rendering of its raw code: an incremental
4
+ model's `is_incremental()` branch is taken or not, and the manifest holds only
5
+ the one dbt rendered. To analyze the other, and to map a compiled line back to
6
+ the line of the model file, this module renders raw code itself.
7
+
8
+ Supported: data, `{# comments #}`, `{{ ref(...) }}`, `{{ source(...) }}`,
9
+ `{{ this }}`, `{{ config(...) }}` (which renders nothing), and
10
+ `{% if [not] is_incremental() %} ... {% else %} ... {% endif %}`, with
11
+ whitespace control. Anything else raises `Unrenderable`: a variable, a
12
+ filter, a loop or another macro could render anything, and guessing what
13
+ would be unsound. A caller trusts a rendering only after the same renderer
14
+ reproduced dbt's own compiled code for the branch dbt took.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+
23
+ # Renders `ref`, `source` or `this` (with no arguments) from its literal arguments.
24
+ type Resolver = Callable[[str, tuple[object, ...], dict[str, object]], str]
25
+
26
+
27
+ class Unrenderable(Exception):
28
+ """Raw code outside the supported subset."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Rendered:
33
+ """Rendered text, and for each of its characters the raw-code offset it was copied from."""
34
+
35
+ text: str
36
+ sources: tuple[int, ...]
37
+
38
+ def raw_offset(self, offset: int) -> int | None:
39
+ """The raw-code offset of rendered `offset`, or None past the end or
40
+ for text a tag produced (a resolved ref, say), which no raw line wrote."""
41
+ return self.sources[offset] if 0 <= offset < len(self.sources) and self.sources[offset] >= 0 else None
42
+
43
+
44
+ _CLOSERS = {"{{": "}}", "{%": "%}", "{#": "#}"}
45
+
46
+
47
+ def has_incremental_branch(raw: str) -> bool:
48
+ return "is_incremental" in raw
49
+
50
+
51
+ def render(raw: str, *, incremental: bool, resolve: Resolver) -> Rendered:
52
+ text: list[str] = []
53
+ sources: list[int] = []
54
+ # Each open `if`: whether its current branch renders.
55
+ branches: list[bool] = []
56
+ strip_next = False
57
+ position = 0
58
+ while True:
59
+ start = _next_tag(raw, position)
60
+ data_end = len(raw) if start < 0 else start
61
+ opener = "" if start < 0 else raw[start:start + 2]
62
+ left_strip = start >= 0 and raw.startswith("-", start + 2)
63
+ data = range(position, data_end)
64
+ if strip_next:
65
+ data = range(_skip_space(raw, position, data_end), data_end)
66
+ if left_strip:
67
+ data = range(data.start, _trim_space(raw, data.start, data.stop))
68
+ if all(branches):
69
+ text.extend(raw[index] for index in data)
70
+ sources.extend(data)
71
+ if start < 0:
72
+ break
73
+ end = _tag_end(raw, start, _CLOSERS[opener])
74
+ body = raw[start + 2:end].removeprefix("-")
75
+ strip_next = body.endswith("-")
76
+ body = body.removesuffix("-").strip()
77
+ position = end + 2
78
+ match opener:
79
+ case "{#":
80
+ pass
81
+ case "{{":
82
+ if all(branches):
83
+ rendered = _expression(body, resolve)
84
+ text.extend(rendered)
85
+ sources.extend([-1] * len(rendered))
86
+ case _:
87
+ _statement(body, branches, incremental)
88
+ if branches:
89
+ raise Unrenderable("an {% if %} is never closed")
90
+ # Jinja drops a single trailing newline (`keep_trailing_newline` is off in dbt).
91
+ if text and text[-1] == "\n":
92
+ text.pop()
93
+ sources.pop()
94
+ return Rendered("".join(text), tuple(sources))
95
+
96
+
97
+ def _next_tag(raw: str, position: int) -> int:
98
+ found = [index for opener in _CLOSERS if (index := raw.find(opener, position)) >= 0]
99
+ return min(found, default=-1)
100
+
101
+
102
+ def _tag_end(raw: str, start: int, closer: str) -> int:
103
+ """The offset of `closer` ending the tag opened at `start`, skipping quoted text."""
104
+ index, quote = start + 2, None
105
+ while index < len(raw):
106
+ char = raw[index]
107
+ if quote:
108
+ if char == "\\":
109
+ index += 2
110
+ continue
111
+ if char == quote:
112
+ quote = None
113
+ elif char in "'\"" and closer != "#}":
114
+ quote = char
115
+ elif raw.startswith(closer, index):
116
+ return index
117
+ index += 1
118
+ raise Unrenderable(f"a tag opened at offset {start} is never closed")
119
+
120
+
121
+ def _skip_space(raw: str, start: int, stop: int) -> int:
122
+ while start < stop and raw[start].isspace():
123
+ start += 1
124
+ return start
125
+
126
+
127
+ def _trim_space(raw: str, start: int, stop: int) -> int:
128
+ while stop > start and raw[stop - 1].isspace():
129
+ stop -= 1
130
+ return stop
131
+
132
+
133
+ def _expression(body: str, resolve: Resolver) -> str:
134
+ try:
135
+ tree = ast.parse(body, mode="eval").body
136
+ except SyntaxError as error:
137
+ raise Unrenderable(f"{{{{ {body} }}}} is not a supported expression") from error
138
+ match tree:
139
+ case ast.Name(id="this"):
140
+ return resolve("this", (), {})
141
+ case ast.Call(func=ast.Name(id="config")):
142
+ return ""
143
+ case ast.Call(func=ast.Name(id="ref" | "source" as name), args=args, keywords=keywords):
144
+ values = tuple(_literal(argument, body) for argument in args)
145
+ options = {keyword.arg: _literal(keyword.value, body) for keyword in keywords if keyword.arg is not None}
146
+ if len(options) != len(keywords):
147
+ raise Unrenderable(f"{{{{ {body} }}}} passes **arguments")
148
+ return resolve(name, values, options)
149
+ case _:
150
+ raise Unrenderable(f"{{{{ {body} }}}} is not a ref, source, this or config")
151
+
152
+
153
+ def _literal(node: ast.expr, body: str) -> object:
154
+ if isinstance(node, ast.Constant) and isinstance(node.value, (str, int, float)):
155
+ return node.value
156
+ raise Unrenderable(f"{{{{ {body} }}}} has an argument that is not a literal")
157
+
158
+
159
+ def _statement(body: str, branches: list[bool], incremental: bool) -> None:
160
+ keyword, _, rest = body.partition(" ")
161
+ match keyword:
162
+ case "if":
163
+ branches.append(_condition(rest.strip(), incremental))
164
+ case "else" if branches and not rest.strip():
165
+ branches[-1] = not branches[-1]
166
+ case "endif" if branches and not rest.strip():
167
+ branches.pop()
168
+ case _:
169
+ raise Unrenderable(f"{{% {body} %}} is not an is_incremental() branch")
170
+
171
+
172
+ def _condition(text: str, incremental: bool) -> bool:
173
+ try:
174
+ tree = ast.parse(text, mode="eval").body
175
+ except SyntaxError as error:
176
+ raise Unrenderable(f"{{% if {text} %}} is not a supported condition") from error
177
+ match tree:
178
+ case ast.Call(func=ast.Name(id="is_incremental"), args=[], keywords=[]):
179
+ return incremental
180
+ case ast.UnaryOp(op=ast.Not(), operand=ast.Call(func=ast.Name(id="is_incremental"), args=[], keywords=[])):
181
+ return not incremental
182
+ case _:
183
+ raise Unrenderable(f"{{% if {text} %}} is not is_incremental()")