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.
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """dbt-costgate — BigQuery cost gate for dbt pull requests."""
3
+
4
+ __version__ = "0.7.1"
@@ -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
@@ -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)