dbt-costgate 0.7.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.
- dbt_costgate/__init__.py +4 -0
- dbt_costgate/against.py +150 -0
- dbt_costgate/artifacts.py +279 -0
- dbt_costgate/bigquery.py +110 -0
- dbt_costgate/cli.py +394 -0
- dbt_costgate/config.py +291 -0
- dbt_costgate/data/__init__.py +3 -0
- dbt_costgate/data/pricing.json +12 -0
- dbt_costgate/estimate.py +185 -0
- dbt_costgate/gitdiff.py +75 -0
- dbt_costgate/models.py +180 -0
- dbt_costgate/policy.py +63 -0
- dbt_costgate/pricing.py +94 -0
- dbt_costgate/report.py +252 -0
- dbt_costgate-0.7.1.dist-info/METADATA +218 -0
- dbt_costgate-0.7.1.dist-info/RECORD +20 -0
- dbt_costgate-0.7.1.dist-info/WHEEL +4 -0
- dbt_costgate-0.7.1.dist-info/entry_points.txt +2 -0
- dbt_costgate-0.7.1.dist-info/licenses/LICENSE +201 -0
- dbt_costgate-0.7.1.dist-info/licenses/NOTICE +25 -0
dbt_costgate/__init__.py
ADDED
dbt_costgate/against.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Auto-compile a base ref as the diff baseline, in an isolated git worktree.
|
|
3
|
+
|
|
4
|
+
The opt-in local convenience carved out by ADR-0006: unlike the pure core (which
|
|
5
|
+
only consumes compiled artifacts) and gitdiff.py (git-only), this edge needs
|
|
6
|
+
*both* git and dbt. It checks ``<ref>`` out into a throwaway worktree, runs
|
|
7
|
+
``dbt compile`` there, and hands the resulting manifest back as the baseline — so
|
|
8
|
+
a local before/after diff is one command with no manual ``--baseline``.
|
|
9
|
+
|
|
10
|
+
The dbt project may sit at the git repo root (the common case) or in a
|
|
11
|
+
subdirectory (monorepos): the repo root is detected via ``git rev-parse
|
|
12
|
+
--show-toplevel``, so the worktree compile targets the project's actual location.
|
|
13
|
+
Every failure degrades to an ``AgainstError`` with an actionable message; the
|
|
14
|
+
worktree is always removed, even on failure or Ctrl+C.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from dbt_costgate import artifacts
|
|
27
|
+
from dbt_costgate.models import ModelNode
|
|
28
|
+
|
|
29
|
+
# Third-party packages live here and are gitignored, so a fresh worktree checkout
|
|
30
|
+
# omits them. dbt_modules is the pre-1.0 name; both are handled for old projects.
|
|
31
|
+
_PACKAGE_DIRS = ("dbt_packages", "dbt_modules")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgainstError(Exception):
|
|
35
|
+
"""Producing the baseline via a worktree compile was not possible."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git(project_dir: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
|
39
|
+
proc = subprocess.run(["git", *args], cwd=project_dir, capture_output=True, text=True)
|
|
40
|
+
if check and proc.returncode != 0:
|
|
41
|
+
raise AgainstError(proc.stderr.strip() or f"git {' '.join(args)} failed")
|
|
42
|
+
return proc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_dbt() -> str:
|
|
46
|
+
"""Find dbt without trusting a bare name on $PATH (shell aliases are invisible
|
|
47
|
+
to subprocess; venvs may not export bin/ to child shells)."""
|
|
48
|
+
found = shutil.which("dbt")
|
|
49
|
+
if found:
|
|
50
|
+
return found
|
|
51
|
+
candidate = Path(sys.executable).parent / "dbt"
|
|
52
|
+
if candidate.is_file():
|
|
53
|
+
return str(candidate)
|
|
54
|
+
raise AgainstError(
|
|
55
|
+
"dbt executable not found on PATH or active virtual environment. "
|
|
56
|
+
"Install dbt, or use --baseline with a pre-compiled manifest."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _dbt_compile(worktree_dir: Path) -> None:
|
|
61
|
+
"""Default compiler: run `dbt compile` in the worktree. Injectable so tests
|
|
62
|
+
exercise the whole flow without dbt, a warehouse, or credentials."""
|
|
63
|
+
proc = subprocess.run(
|
|
64
|
+
[_resolve_dbt(), "compile"], cwd=worktree_dir, capture_output=True, text=True
|
|
65
|
+
)
|
|
66
|
+
if proc.returncode != 0:
|
|
67
|
+
tail = "\n".join((proc.stderr or proc.stdout).strip().splitlines()[-8:])
|
|
68
|
+
raise AgainstError("dbt compile failed for the baseline ref:\n" + tail)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _link_packages(src_dir: Path, worktree_dir: Path) -> None:
|
|
72
|
+
"""Make host-installed dbt packages visible to the worktree compile.
|
|
73
|
+
|
|
74
|
+
A fresh checkout omits gitignored dbt_packages/, so a project using dbt_utils
|
|
75
|
+
et al. would crash at compile on the first package macro. Symlink the host's
|
|
76
|
+
copy in (fast, offline); copy where symlinks aren't available. These are the
|
|
77
|
+
*current branch's* installed versions — good enough for a cost baseline.
|
|
78
|
+
"""
|
|
79
|
+
for name in _PACKAGE_DIRS:
|
|
80
|
+
src = (src_dir / name).resolve()
|
|
81
|
+
if not src.is_dir():
|
|
82
|
+
continue
|
|
83
|
+
dest = worktree_dir / name
|
|
84
|
+
if dest.exists() or dest.is_symlink():
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
dest.symlink_to(src, target_is_directory=True)
|
|
88
|
+
except OSError:
|
|
89
|
+
shutil.copytree(src, dest)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compiled_baseline(
|
|
93
|
+
ref: str,
|
|
94
|
+
project_dir: Path,
|
|
95
|
+
*,
|
|
96
|
+
compile_fn: Callable[[Path], None] | None = None,
|
|
97
|
+
) -> dict[str, ModelNode]:
|
|
98
|
+
"""Compile ``ref`` in a throwaway worktree; return its model nodes as the
|
|
99
|
+
baseline. Raises AgainstError (never a stack trace) on any failure."""
|
|
100
|
+
# Resolved at call time (not bound as a default) so the real compiler can be
|
|
101
|
+
# monkeypatched in tests that drive the full worktree flow through the CLI.
|
|
102
|
+
if compile_fn is None:
|
|
103
|
+
compile_fn = _dbt_compile
|
|
104
|
+
project_dir = project_dir.resolve()
|
|
105
|
+
|
|
106
|
+
# Self-heal any worktree orphaned by a prior SIGKILL/power-loss — the one case
|
|
107
|
+
# the finally-block below genuinely can't cover.
|
|
108
|
+
_git(project_dir, "worktree", "prune", check=False)
|
|
109
|
+
|
|
110
|
+
# `git worktree add` checks out the *whole* repo, so a dbt project living in a
|
|
111
|
+
# subdir lands at <worktree>/<subdir>. Locate that subdir relative to the repo
|
|
112
|
+
# root (both resolved so the macOS /tmp -> /private/tmp symlink doesn't break
|
|
113
|
+
# relative_to).
|
|
114
|
+
repo_root = Path(_git(project_dir, "rev-parse", "--show-toplevel").stdout.strip()).resolve()
|
|
115
|
+
try:
|
|
116
|
+
subdir = project_dir.relative_to(repo_root)
|
|
117
|
+
except ValueError:
|
|
118
|
+
raise AgainstError(
|
|
119
|
+
f"project dir {project_dir} is not inside the git repo at {repo_root}."
|
|
120
|
+
) from None
|
|
121
|
+
|
|
122
|
+
verify = _git(project_dir, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}", check=False)
|
|
123
|
+
if verify.returncode != 0:
|
|
124
|
+
raise AgainstError(
|
|
125
|
+
f"couldn't resolve ref {ref!r} to compile as the baseline. Use an "
|
|
126
|
+
f"existing branch/tag/SHA, or --baseline with a pre-compiled manifest."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# mkdtemp creates the parent; the worktree path itself must not exist yet, as
|
|
130
|
+
# `git worktree add` creates it.
|
|
131
|
+
tmp_parent = Path(tempfile.mkdtemp(prefix="dbt-costgate-against-"))
|
|
132
|
+
worktree = tmp_parent / "wt"
|
|
133
|
+
try:
|
|
134
|
+
_git(project_dir, "worktree", "add", "--detach", "--quiet", str(worktree), ref)
|
|
135
|
+
project_in_wt = worktree / subdir
|
|
136
|
+
if not project_in_wt.is_dir():
|
|
137
|
+
raise AgainstError(
|
|
138
|
+
f"the dbt project dir {str(subdir)!r} doesn't exist in ref {ref!r} "
|
|
139
|
+
f"— it may have been added or moved after that ref."
|
|
140
|
+
)
|
|
141
|
+
_link_packages(project_dir, project_in_wt)
|
|
142
|
+
compile_fn(project_in_wt)
|
|
143
|
+
manifest = artifacts.load_manifest(project_in_wt / "target")
|
|
144
|
+
return artifacts.model_nodes(manifest)
|
|
145
|
+
except artifacts.ArtifactError as exc:
|
|
146
|
+
raise AgainstError(f"the baseline compile produced no usable manifest: {exc}") from exc
|
|
147
|
+
finally:
|
|
148
|
+
_git(project_dir, "worktree", "remove", "--force", str(worktree), check=False)
|
|
149
|
+
_git(project_dir, "worktree", "prune", check=False)
|
|
150
|
+
shutil.rmtree(tmp_parent, ignore_errors=True)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Read dbt artifacts and select the cost-bearing models to estimate.
|
|
3
|
+
|
|
4
|
+
Pure functions over manifest JSON — no dbt or git invocation. The only I/O is
|
|
5
|
+
reading files the user already produced with ``dbt compile``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from dbt_costgate.models import EstimateBasis, ModelNode
|
|
16
|
+
|
|
17
|
+
# Statically-unknowable filters make a BigQuery dry-run fall back to a full-table
|
|
18
|
+
# scan (see docs/architecture.md). We can't correct the number, only flag it.
|
|
19
|
+
_DYNAMIC_FILTER = re.compile(
|
|
20
|
+
r"\b(current_date|current_timestamp|current_datetime)\b", re.IGNORECASE
|
|
21
|
+
)
|
|
22
|
+
_SUBQUERY_IN_PREDICATE = re.compile(r"\b(where|and|or)\b[^;]*\(\s*select\b", re.IGNORECASE)
|
|
23
|
+
_MULTI_STATEMENT = re.compile(r"\b(declare|begin)\b", re.IGNORECASE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ArtifactError(Exception):
|
|
27
|
+
"""A manifest could not be read or is missing information dbt-costgate needs."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def manifest_path(path: Path) -> Path:
|
|
31
|
+
"""Accept either a manifest.json or a dbt ``target/`` directory."""
|
|
32
|
+
if path.is_dir():
|
|
33
|
+
return path / "manifest.json"
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_manifest(path: Path) -> dict:
|
|
38
|
+
mpath = manifest_path(path)
|
|
39
|
+
if not mpath.is_file():
|
|
40
|
+
raise ArtifactError(
|
|
41
|
+
f"No manifest.json at {mpath}. Run `dbt compile` first, then point "
|
|
42
|
+
f"--current at the target/ directory (or pass the manifest path)."
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(mpath.read_text("utf-8"))
|
|
46
|
+
except json.JSONDecodeError as exc: # pragma: no cover - defensive
|
|
47
|
+
raise ArtifactError(f"{mpath} is not valid JSON: {exc}") from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def model_nodes(manifest: dict) -> dict[str, ModelNode]:
|
|
51
|
+
"""Cost-bearing model nodes only, keyed by unique_id.
|
|
52
|
+
|
|
53
|
+
Filters to SQL models: skips Python models (compiled_code is Python, not SQL),
|
|
54
|
+
ephemeral models (no queryable relation), and every non-model resource type.
|
|
55
|
+
"""
|
|
56
|
+
out: dict[str, ModelNode] = {}
|
|
57
|
+
for unique_id, node in (manifest.get("nodes") or {}).items():
|
|
58
|
+
if node.get("resource_type") != "model":
|
|
59
|
+
continue
|
|
60
|
+
if node.get("language", "sql") != "sql":
|
|
61
|
+
continue
|
|
62
|
+
config = node.get("config") or {}
|
|
63
|
+
if config.get("materialized") == "ephemeral":
|
|
64
|
+
continue
|
|
65
|
+
checksum = (node.get("checksum") or {}).get("checksum")
|
|
66
|
+
out[unique_id] = ModelNode(
|
|
67
|
+
unique_id=unique_id,
|
|
68
|
+
name=node.get("name", unique_id),
|
|
69
|
+
materialized=config.get("materialized", "view"),
|
|
70
|
+
language=node.get("language", "sql"),
|
|
71
|
+
database=node.get("database"),
|
|
72
|
+
schema=node.get("schema"),
|
|
73
|
+
relation_name=node.get("relation_name"),
|
|
74
|
+
checksum=checksum,
|
|
75
|
+
compiled_path=node.get("compiled_path"),
|
|
76
|
+
compiled_code=node.get("compiled_code"),
|
|
77
|
+
original_file_path=node.get("original_file_path"),
|
|
78
|
+
patch_path=node.get("patch_path"),
|
|
79
|
+
depends_on_macros=list((node.get("depends_on") or {}).get("macros") or []),
|
|
80
|
+
)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def has_any_compiled_code(nodes: dict[str, ModelNode]) -> bool:
|
|
85
|
+
return any(n.compiled_code for n in nodes.values())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_compiled_sql(node: ModelNode, project_dir: Path | None) -> str | None:
|
|
89
|
+
"""Prefer the on-disk compiled file (always written by ``dbt compile``);
|
|
90
|
+
fall back to the manifest's inlined ``compiled_code``."""
|
|
91
|
+
if node.compiled_path and project_dir is not None:
|
|
92
|
+
candidate = project_dir / node.compiled_path
|
|
93
|
+
if candidate.is_file():
|
|
94
|
+
return candidate.read_text("utf-8")
|
|
95
|
+
return node.compiled_code
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_ref(ref: str, nodes: dict[str, ModelNode], side: str) -> str:
|
|
99
|
+
"""Resolve a user-supplied model reference to a unique_id. Accepts a full
|
|
100
|
+
unique_id (used verbatim) or a bare model name (looked up by ``.name``)."""
|
|
101
|
+
if ref in nodes:
|
|
102
|
+
return ref
|
|
103
|
+
matches = [uid for uid, n in nodes.items() if n.name == ref]
|
|
104
|
+
if len(matches) == 1:
|
|
105
|
+
return matches[0]
|
|
106
|
+
if not matches:
|
|
107
|
+
raise ArtifactError(
|
|
108
|
+
f"renames: {side} model {ref!r} not found. Use its dbt model name or its "
|
|
109
|
+
f"full unique_id (e.g. model.<package>.<name>)."
|
|
110
|
+
)
|
|
111
|
+
raise ArtifactError(
|
|
112
|
+
f"renames: {side} model name {ref!r} is ambiguous across packages "
|
|
113
|
+
f"({', '.join(sorted(matches))}). Disambiguate with the full unique_id."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_renames(
|
|
118
|
+
renames: dict[str, str],
|
|
119
|
+
current: dict[str, ModelNode],
|
|
120
|
+
baseline: dict[str, ModelNode],
|
|
121
|
+
) -> dict[str, str]:
|
|
122
|
+
"""Turn a user ``renames`` map (current -> baseline, by name or unique_id) into
|
|
123
|
+
a ``current_uid -> baseline_uid`` map against the loaded manifests. Fails loudly
|
|
124
|
+
on an unresolvable, ambiguous, or many-to-one mapping rather than mis-diffing."""
|
|
125
|
+
resolved: dict[str, str] = {}
|
|
126
|
+
for current_ref, baseline_ref in renames.items():
|
|
127
|
+
cur_uid = _resolve_ref(current_ref, current, "current")
|
|
128
|
+
base_uid = _resolve_ref(baseline_ref, baseline, "baseline")
|
|
129
|
+
resolved[cur_uid] = base_uid
|
|
130
|
+
if len(set(resolved.values())) < len(resolved):
|
|
131
|
+
raise ArtifactError(
|
|
132
|
+
"renames: two or more current models map to the same baseline model; "
|
|
133
|
+
"each baseline may be paired with only one current model."
|
|
134
|
+
)
|
|
135
|
+
return resolved
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _compiled_differs(base: ModelNode, node: ModelNode) -> bool:
|
|
139
|
+
"""Whether the two versions compile to different SQL. A missing side is
|
|
140
|
+
*unknown*, not different — a parse-only manifest must never select everything."""
|
|
141
|
+
if base.compiled_code is None or node.compiled_code is None:
|
|
142
|
+
return False
|
|
143
|
+
return base.compiled_code != node.compiled_code
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def select_changed(
|
|
147
|
+
baseline: dict[str, ModelNode],
|
|
148
|
+
current: dict[str, ModelNode],
|
|
149
|
+
renames: dict[str, str] | None = None,
|
|
150
|
+
) -> list[str]:
|
|
151
|
+
"""Models that were added, whose body changed (checksum differs), or whose
|
|
152
|
+
compiled SQL differs — the last catching config-only and macro-only changes,
|
|
153
|
+
which never touch the model's own file. A declared rename (``current_uid`` in
|
|
154
|
+
``renames``) is always selected."""
|
|
155
|
+
renames = renames or {}
|
|
156
|
+
changed = []
|
|
157
|
+
for uid, node in current.items():
|
|
158
|
+
if uid in renames:
|
|
159
|
+
changed.append(uid)
|
|
160
|
+
continue
|
|
161
|
+
base = baseline.get(uid)
|
|
162
|
+
if base is None:
|
|
163
|
+
changed.append(uid)
|
|
164
|
+
elif node.checksum is None or base.checksum is None or node.checksum != base.checksum:
|
|
165
|
+
changed.append(uid)
|
|
166
|
+
elif _compiled_differs(base, node):
|
|
167
|
+
changed.append(uid)
|
|
168
|
+
return changed
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def indirect_changes(baseline: dict[str, ModelNode], current: dict[str, ModelNode]) -> set[str]:
|
|
172
|
+
"""The models ``select_changed`` picks up *only* from their compiled SQL. Their
|
|
173
|
+
own file is untouched, so the cause is upstream — surfaced as a warning so a
|
|
174
|
+
model never appears in a report without an explanation."""
|
|
175
|
+
out: set[str] = set()
|
|
176
|
+
for uid, node in current.items():
|
|
177
|
+
base = baseline.get(uid)
|
|
178
|
+
if base is None or node.checksum is None or base.checksum is None:
|
|
179
|
+
continue
|
|
180
|
+
if node.checksum == base.checksum and _compiled_differs(base, node):
|
|
181
|
+
out.add(uid)
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class MacroIndex:
|
|
187
|
+
"""The macro graph as the manifest records it: the file each macro is defined
|
|
188
|
+
in, and the macros each macro calls."""
|
|
189
|
+
|
|
190
|
+
files: dict[str, str] # macro unique_id -> original_file_path
|
|
191
|
+
depends_on: dict[str, list[str]] # macro unique_id -> macro unique_ids it calls
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def macro_index(manifest: dict) -> MacroIndex:
|
|
195
|
+
files: dict[str, str] = {}
|
|
196
|
+
depends_on: dict[str, list[str]] = {}
|
|
197
|
+
for uid, macro in (manifest.get("macros") or {}).items():
|
|
198
|
+
path = macro.get("original_file_path")
|
|
199
|
+
if path:
|
|
200
|
+
files[uid] = path
|
|
201
|
+
depends_on[uid] = list((macro.get("depends_on") or {}).get("macros") or [])
|
|
202
|
+
return MacroIndex(files=files, depends_on=depends_on)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _changed_macros(paths: set[str], index: MacroIndex) -> set[str]:
|
|
206
|
+
"""Macros defined in a changed file, plus every macro that calls one — dbt
|
|
207
|
+
treats the caller of a changed macro as changed too, and so must we."""
|
|
208
|
+
callers: dict[str, set[str]] = {}
|
|
209
|
+
for uid, deps in index.depends_on.items():
|
|
210
|
+
for dep in deps:
|
|
211
|
+
callers.setdefault(dep, set()).add(uid)
|
|
212
|
+
stack = [uid for uid, path in index.files.items() if path in paths]
|
|
213
|
+
seen: set[str] = set()
|
|
214
|
+
while stack:
|
|
215
|
+
uid = stack.pop()
|
|
216
|
+
if uid in seen:
|
|
217
|
+
continue
|
|
218
|
+
seen.add(uid)
|
|
219
|
+
stack.extend(callers.get(uid, ()))
|
|
220
|
+
return seen
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _patch_file(patch_path: str) -> str:
|
|
224
|
+
"""``patch_path`` is ``<package>://<path>``; the path half is project-relative,
|
|
225
|
+
the same basis as ``original_file_path``."""
|
|
226
|
+
return patch_path.split("://", 1)[-1]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def select_by_paths(
|
|
230
|
+
nodes: dict[str, ModelNode],
|
|
231
|
+
paths: list[str],
|
|
232
|
+
macros: MacroIndex | None = None,
|
|
233
|
+
) -> list[str]:
|
|
234
|
+
"""Models a set of changed project-relative paths touches: the model's own file,
|
|
235
|
+
the YAML that patches it, or any macro in its macro closure. Needs no baseline
|
|
236
|
+
manifest, so it drives the zero-setup local run."""
|
|
237
|
+
changed = set(paths)
|
|
238
|
+
changed_macros = _changed_macros(changed, macros) if macros else set()
|
|
239
|
+
selected = []
|
|
240
|
+
for uid, node in nodes.items():
|
|
241
|
+
if node.original_file_path and node.original_file_path in changed:
|
|
242
|
+
selected.append(uid)
|
|
243
|
+
elif node.patch_path and _patch_file(node.patch_path) in changed:
|
|
244
|
+
selected.append(uid)
|
|
245
|
+
elif changed_macros.intersection(node.depends_on_macros):
|
|
246
|
+
selected.append(uid)
|
|
247
|
+
return selected
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def touches_project_config(paths: list[str]) -> bool:
|
|
251
|
+
"""A ``dbt_project.yml`` change is project-wide config — real, but not
|
|
252
|
+
attributable to individual models from paths alone. Paths are project-relative,
|
|
253
|
+
so only this project's file matches, never a sibling project's."""
|
|
254
|
+
return any(p == "dbt_project.yml" for p in paths)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def detect_basis(node: ModelNode, sql: str | None) -> EstimateBasis:
|
|
258
|
+
"""A self-reference in an incremental model's compiled SQL means it was
|
|
259
|
+
compiled against an existing table (incremental form); its absence means the
|
|
260
|
+
full-refresh form. Non-incrementals are always direct."""
|
|
261
|
+
if not node.is_incremental:
|
|
262
|
+
return EstimateBasis.DIRECT
|
|
263
|
+
if sql and node.relation_name and node.relation_name in sql:
|
|
264
|
+
return EstimateBasis.INCREMENTAL_FORM
|
|
265
|
+
return EstimateBasis.FULL_REFRESH
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def sql_warnings(node: ModelNode, sql: str | None) -> list[str]:
|
|
269
|
+
"""Non-fatal caveats about how trustworthy a dry-run of this SQL will be."""
|
|
270
|
+
warnings: list[str] = []
|
|
271
|
+
if not sql:
|
|
272
|
+
return warnings
|
|
273
|
+
if _DYNAMIC_FILTER.search(sql) or _SUBQUERY_IN_PREDICATE.search(sql):
|
|
274
|
+
warnings.append("dynamic filter — dry-run may be worst-case (overestimate)")
|
|
275
|
+
if _MULTI_STATEMENT.search(sql):
|
|
276
|
+
warnings.append("multi-statement — dry-run bytes may be partial")
|
|
277
|
+
if node.is_incremental:
|
|
278
|
+
warnings.append("incremental — figure is the full-refresh scan")
|
|
279
|
+
return warnings
|
dbt_costgate/bigquery.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The one network edge: BigQuery dry-run, behind a ``DryRunner`` protocol.
|
|
3
|
+
|
|
4
|
+
dbt-costgate's only warehouse interaction. `dryRun=true` executes nothing, reads no
|
|
5
|
+
table data, and is never billed. Credentials are never handled here — the client
|
|
6
|
+
resolves them through Application Default Credentials, exactly like dbt-bigquery.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from dbt_costgate.models import ErrorKind
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DryRunResult:
|
|
19
|
+
total_bytes: int | None = None
|
|
20
|
+
location: str | None = None
|
|
21
|
+
error_kind: ErrorKind | None = None
|
|
22
|
+
error_detail: str | None = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def ok(self) -> bool:
|
|
26
|
+
return self.error_kind is None and self.total_bytes is not None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DryRunner(Protocol):
|
|
30
|
+
def dry_run(self, sql: str, self_relation: str | None = None) -> DryRunResult: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def categorize(exc: Exception, self_relation: str | None) -> tuple[ErrorKind, str]:
|
|
34
|
+
"""Map a BigQuery client exception to a dbt-costgate error kind.
|
|
35
|
+
|
|
36
|
+
Matches on exception type name and message rather than importing google
|
|
37
|
+
exception classes, so the pure test suite can exercise it with stand-ins.
|
|
38
|
+
"""
|
|
39
|
+
name = type(exc).__name__
|
|
40
|
+
msg = str(exc)
|
|
41
|
+
low = msg.lower()
|
|
42
|
+
if "notfound" in name.lower() or "not found" in low:
|
|
43
|
+
if self_relation and _relation_in_message(self_relation, msg):
|
|
44
|
+
return ErrorKind.DESTINATION_MISSING, msg
|
|
45
|
+
return ErrorKind.UPSTREAM_MISSING, msg
|
|
46
|
+
if "forbidden" in name.lower() or "permissiondenied" in name.lower() or "permission" in low:
|
|
47
|
+
return ErrorKind.PERMISSION, msg
|
|
48
|
+
if (
|
|
49
|
+
"serviceunavailable" in name.lower()
|
|
50
|
+
or "internalserver" in name.lower()
|
|
51
|
+
or "toomanyrequests" in name.lower()
|
|
52
|
+
or any(code in msg for code in ("429", "500", "503"))
|
|
53
|
+
):
|
|
54
|
+
return ErrorKind.TRANSIENT, msg
|
|
55
|
+
if "badrequest" in name.lower() or "invalid" in low or "syntax" in low:
|
|
56
|
+
return ErrorKind.INVALID_SQL, msg
|
|
57
|
+
return ErrorKind.OTHER, msg
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _relation_in_message(relation: str, msg: str) -> bool:
|
|
61
|
+
"""A dbt relation_name looks like `project`.`dataset`.`table`; BigQuery's
|
|
62
|
+
404 text uses `project:dataset.table` or `dataset.table`. Compare on the bare
|
|
63
|
+
table token so backticks/separators don't matter."""
|
|
64
|
+
table = relation.replace("`", "").split(".")[-1]
|
|
65
|
+
return bool(table) and table in msg
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BigQueryDryRunner:
|
|
69
|
+
"""Real dry-runner. Imports google-cloud-bigquery lazily so the module (and
|
|
70
|
+
the pure test suite) load without credentials or the client installed."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, project: str | None = None, deadline_seconds: float = 60.0):
|
|
73
|
+
self._project = project
|
|
74
|
+
self._deadline = deadline_seconds
|
|
75
|
+
self._client = None
|
|
76
|
+
self._retry = None
|
|
77
|
+
|
|
78
|
+
def _ensure_client(self):
|
|
79
|
+
if self._client is not None:
|
|
80
|
+
return
|
|
81
|
+
from google.api_core import exceptions as gexc
|
|
82
|
+
from google.api_core.retry import Retry
|
|
83
|
+
from google.cloud import bigquery
|
|
84
|
+
|
|
85
|
+
self._bigquery = bigquery
|
|
86
|
+
self._client = bigquery.Client(project=self._project)
|
|
87
|
+
self._retry = Retry(
|
|
88
|
+
predicate=lambda e: isinstance(
|
|
89
|
+
e,
|
|
90
|
+
(
|
|
91
|
+
gexc.TooManyRequests,
|
|
92
|
+
gexc.ServiceUnavailable,
|
|
93
|
+
gexc.InternalServerError,
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
deadline=self._deadline,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def dry_run(self, sql: str, self_relation: str | None = None) -> DryRunResult:
|
|
100
|
+
try:
|
|
101
|
+
self._ensure_client()
|
|
102
|
+
except Exception as exc: # ADC / import failure — operational, surfaced by caller
|
|
103
|
+
return DryRunResult(error_kind=ErrorKind.OTHER, error_detail=str(exc))
|
|
104
|
+
job_config = self._bigquery.QueryJobConfig(dry_run=True, use_query_cache=False)
|
|
105
|
+
try:
|
|
106
|
+
job = self._client.query(sql, job_config=job_config, retry=self._retry)
|
|
107
|
+
return DryRunResult(total_bytes=int(job.total_bytes_processed), location=job.location)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
kind, detail = categorize(exc, self_relation)
|
|
110
|
+
return DryRunResult(error_kind=kind, error_detail=detail)
|