dbt-swap 1.0__tar.gz
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_swap-1.0/PKG-INFO +9 -0
- dbt_swap-1.0/pyproject.toml +31 -0
- dbt_swap-1.0/src/dbt_swap/__init__.py +0 -0
- dbt_swap-1.0/src/dbt_swap/cli/__init__.py +0 -0
- dbt_swap-1.0/src/dbt_swap/cli/common.py +24 -0
- dbt_swap-1.0/src/dbt_swap/cli/main.py +25 -0
- dbt_swap-1.0/src/dbt_swap/cli/smart_build.py +56 -0
- dbt_swap-1.0/src/dbt_swap/core/__init__.py +0 -0
- dbt_swap-1.0/src/dbt_swap/core/smart_builder.py +270 -0
- dbt_swap-1.0/src/dbt_swap/utils/__init__.py +0 -0
- dbt_swap-1.0/src/dbt_swap/utils/logging.py +17 -0
dbt_swap-1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: dbt-swap
|
|
3
|
+
Version: 1.0
|
|
4
|
+
Summary: A cli tool that adds a number of custom dbt commands.
|
|
5
|
+
Author: Tobias Cadee
|
|
6
|
+
Author-email: Tobias Cadee <tobias.cadee@ticketswap.com>
|
|
7
|
+
Requires-Dist: sqlglot>=27
|
|
8
|
+
Requires-Dist: typer>=0.19.2
|
|
9
|
+
Requires-Python: >=3.12
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dbt-swap"
|
|
3
|
+
version = "1.0"
|
|
4
|
+
description = "A cli tool that adds a number of custom dbt commands."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Tobias Cadee", email = "tobias.cadee@ticketswap.com" }
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"sqlglot>=27",
|
|
10
|
+
"typer>=0.19.2",
|
|
11
|
+
]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
dbt-swap = "dbt_swap.cli.main:app"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.8.6,<0.9.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.4.2",
|
|
24
|
+
"dbt-core>=1.9.0,<1.10.0",
|
|
25
|
+
"dbt-postgres>=1.9.1",
|
|
26
|
+
"ruff>=0.14.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
log_cli = true
|
|
31
|
+
log_cli_level = 1
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def init_context(target: str, target_path: str, state: str):
|
|
9
|
+
"""Initialize shared CLI context."""
|
|
10
|
+
return {
|
|
11
|
+
"target": target,
|
|
12
|
+
"target_path": target_path,
|
|
13
|
+
"state": state,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_env(target: str, target_path: str, state: str):
|
|
18
|
+
"""Set environment variables for all dbt-swap commands."""
|
|
19
|
+
if target:
|
|
20
|
+
os.environ["DBT_TARGET"] = target
|
|
21
|
+
if target_path:
|
|
22
|
+
os.environ["DBT_TARGET_PATH"] = target_path
|
|
23
|
+
if state:
|
|
24
|
+
os.environ["DBT_STATE_PATH"] = state
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from dbt_swap.cli import smart_build
|
|
3
|
+
from dbt_swap.cli.common import init_context, set_env
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(help="dbt-swap CLI — utilities around dbt state and builds")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Shared context init (runs before each command)
|
|
9
|
+
@app.callback(invoke_without_command=True)
|
|
10
|
+
def main(
|
|
11
|
+
ctx: typer.Context,
|
|
12
|
+
target: str = typer.Option(None, help="DBT target name."),
|
|
13
|
+
target_path: str = typer.Option(None, help="Path to dbt target manifest."),
|
|
14
|
+
state: str = typer.Option("target/", help="Path to dbt state manifest."),
|
|
15
|
+
):
|
|
16
|
+
"""Initialize shared CLI context."""
|
|
17
|
+
ctx.obj = init_context(target=target, target_path=target_path, state=state)
|
|
18
|
+
set_env(target=target, target_path=target_path, state=state)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Register subcommands
|
|
22
|
+
app.command("smart-build")(smart_build.smart_build)
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
app()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import subprocess
|
|
3
|
+
from dbt_swap.core.smart_builder import DbtSmartBuilder
|
|
4
|
+
from dbt_swap.utils.logging import get_logger
|
|
5
|
+
|
|
6
|
+
logger = get_logger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Build only modified models intelligently.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command("smart-build")
|
|
13
|
+
def smart_build(
|
|
14
|
+
ctx: typer.Context,
|
|
15
|
+
dry_run: bool = typer.Option(False, help="Perform a dry run without making changes."),
|
|
16
|
+
verbose: bool = typer.Option(False, help="Enable verbose output."),
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Run a smart build — builds only modified models based on dbt state and column level lineage.
|
|
20
|
+
Additional arguments after `smart-build` are passed directly to `dbt build`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
dbt_smart_build = DbtSmartBuilder()
|
|
24
|
+
modified_nodes = dbt_smart_build.find_modified_nodes()
|
|
25
|
+
|
|
26
|
+
if ctx.args:
|
|
27
|
+
args = ctx.args
|
|
28
|
+
else:
|
|
29
|
+
args = []
|
|
30
|
+
|
|
31
|
+
command = f"dbt build -s {' '.join(modified_nodes)} {' '.join(args)}"
|
|
32
|
+
|
|
33
|
+
if dry_run:
|
|
34
|
+
resource_types = {node["resource_type"] for node in dbt_smart_build.nodes.values()}
|
|
35
|
+
resource_type_counts = {
|
|
36
|
+
resource_type: {
|
|
37
|
+
"smart_count": sum(
|
|
38
|
+
1 for node in modified_nodes if dbt_smart_build.nodes[node]["resource_type"] == resource_type
|
|
39
|
+
),
|
|
40
|
+
"total_count": sum(
|
|
41
|
+
1
|
|
42
|
+
for node in dbt_smart_build.modified_and_downstream_node_ids
|
|
43
|
+
if dbt_smart_build.nodes[node]["resource_type"] == resource_type
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
for resource_type in resource_types
|
|
47
|
+
}
|
|
48
|
+
for resource_type, counts in resource_type_counts.items():
|
|
49
|
+
if counts["total_count"] > 0:
|
|
50
|
+
logger.info(f"[DRY RUN] Would build {counts['smart_count']}/{counts['total_count']} {resource_type}(s)")
|
|
51
|
+
if verbose:
|
|
52
|
+
logger.info("[DRY RUN] Would build:")
|
|
53
|
+
for modified_node in modified_nodes:
|
|
54
|
+
logger.info(modified_node)
|
|
55
|
+
else:
|
|
56
|
+
subprocess.run(command, shell=True, check=True)
|
|
File without changes
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from sqlglot import parse_one, exp
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dbt_swap.utils.logging import get_logger
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DbtSmartBuilder:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
# Allow overriding paths via environment for correctness with dbt --state and target-path
|
|
15
|
+
# DBT_STATE: directory containing a manifest.json representing the comparison state
|
|
16
|
+
# DBT_TARGET_STATE: directory containing the current target manifest.json (after compile)
|
|
17
|
+
self.target_dir = os.environ.get("DBT_TARGET_STATE", "./target")
|
|
18
|
+
self.state_dir = os.environ.get("DBT_STATE", "./state")
|
|
19
|
+
self.target_manifest_path = str(Path(self.target_dir) / "manifest.json")
|
|
20
|
+
self.state_manifest_path = str(Path(self.state_dir) / "manifest.json")
|
|
21
|
+
self.dialect = "postgres" # Default dialect; could be made configurable
|
|
22
|
+
|
|
23
|
+
def list_changed_node_ids(self, downstream: bool = False) -> list[str]:
|
|
24
|
+
"""Get list of changed node unique ids using dbt ls command."""
|
|
25
|
+
logger.info("Getting changed nodes...")
|
|
26
|
+
if downstream:
|
|
27
|
+
selector = "state:modified+"
|
|
28
|
+
else:
|
|
29
|
+
selector = "state:modified"
|
|
30
|
+
output = b""
|
|
31
|
+
try:
|
|
32
|
+
output = subprocess.check_output(
|
|
33
|
+
["dbt", "ls", "--select", selector, "--output", "json", "--quiet"],
|
|
34
|
+
stderr=subprocess.STDOUT,
|
|
35
|
+
)
|
|
36
|
+
except subprocess.CalledProcessError as e:
|
|
37
|
+
logger.error(
|
|
38
|
+
f"Failed to list changed nodes with dbt: {e}\n"
|
|
39
|
+
f"Command: {e.cmd}\n"
|
|
40
|
+
f"Return code: {e.returncode}\n"
|
|
41
|
+
f"Output: {e.output.decode() if e.output else ''}"
|
|
42
|
+
)
|
|
43
|
+
# If dbt ls fails (e.g., wrong working directory or missing state), treat as no changes
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
if not output.strip():
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
changed_files = [json.loads(line) for line in output.decode().splitlines()]
|
|
50
|
+
changed_node_ids = [node["unique_id"] for node in changed_files]
|
|
51
|
+
|
|
52
|
+
return changed_node_ids
|
|
53
|
+
|
|
54
|
+
@cached_property
|
|
55
|
+
def modified_node_ids(self) -> list[str]:
|
|
56
|
+
"""Get list of changed models using dbt ls command."""
|
|
57
|
+
return self.list_changed_node_ids(downstream=False)
|
|
58
|
+
|
|
59
|
+
@cached_property
|
|
60
|
+
def modified_and_downstream_node_ids(self) -> list[str]:
|
|
61
|
+
"""Get list of changed models and their downstream dependencies using dbt ls command."""
|
|
62
|
+
return self.list_changed_node_ids(downstream=True)
|
|
63
|
+
|
|
64
|
+
def compile_changed_nodes(self) -> None:
|
|
65
|
+
"""Compile changed nodes and their dependencies using dbt compile command."""
|
|
66
|
+
logger.info("Compiling changed models and dependencies...")
|
|
67
|
+
subprocess.check_output(
|
|
68
|
+
[
|
|
69
|
+
"dbt",
|
|
70
|
+
"compile",
|
|
71
|
+
"--select",
|
|
72
|
+
"state:modified+",
|
|
73
|
+
"--quiet",
|
|
74
|
+
"--state",
|
|
75
|
+
self.state_dir,
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def load_manifest(self, manifest_path) -> dict:
|
|
80
|
+
"""Load manifest from the given path."""
|
|
81
|
+
path = Path(manifest_path)
|
|
82
|
+
if not path.exists():
|
|
83
|
+
logger.error(f"Manifest not found at {manifest_path}. Run 'dbt compile' first.")
|
|
84
|
+
raise FileNotFoundError(f"Manifest not found at {manifest_path}. Run 'dbt compile' first.")
|
|
85
|
+
|
|
86
|
+
with open(path) as f:
|
|
87
|
+
manifest = json.load(f)
|
|
88
|
+
|
|
89
|
+
return manifest
|
|
90
|
+
|
|
91
|
+
@cached_property
|
|
92
|
+
def manifest(self) -> dict:
|
|
93
|
+
return self.load_manifest(self.target_manifest_path)
|
|
94
|
+
|
|
95
|
+
@cached_property
|
|
96
|
+
def compare_manifest(self) -> dict:
|
|
97
|
+
return self.load_manifest(self.state_manifest_path)
|
|
98
|
+
|
|
99
|
+
@cached_property
|
|
100
|
+
def nodes(self) -> dict:
|
|
101
|
+
"""Get nodes from the target manifest."""
|
|
102
|
+
return self.manifest.get("nodes", {})
|
|
103
|
+
|
|
104
|
+
@cached_property
|
|
105
|
+
def compare_nodes(self) -> dict:
|
|
106
|
+
"""Get nodes from the state manifest."""
|
|
107
|
+
return self.compare_manifest.get("nodes", {})
|
|
108
|
+
|
|
109
|
+
@cached_property
|
|
110
|
+
def child_map(self) -> dict:
|
|
111
|
+
"""Return the child map from target manifest."""
|
|
112
|
+
return self.manifest.get("child_map", {})
|
|
113
|
+
|
|
114
|
+
def find_changed_columns(self, node: dict) -> list[str]:
|
|
115
|
+
"""Find columns that have changed in a node."""
|
|
116
|
+
if node["resource_type"] != "model":
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
sql = node.get("compiled_code", "")
|
|
120
|
+
compare_sql = self.compare_nodes.get(node["unique_id"], {}).get("compiled_code", "select *")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
parsed = [
|
|
124
|
+
projection
|
|
125
|
+
for select in parse_one(sql, dialect=self.dialect).find_all(exp.Select)
|
|
126
|
+
for projection in select.expressions
|
|
127
|
+
]
|
|
128
|
+
compare_parsed = [
|
|
129
|
+
projection
|
|
130
|
+
for select in parse_one(compare_sql, dialect=self.dialect).find_all(exp.Select)
|
|
131
|
+
for projection in select.expressions
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
diffs = set(parsed) - set(compare_parsed)
|
|
135
|
+
return [change.alias_or_name for change in diffs]
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.warning(f"Could not parse SQL for node {node['name']}: {e}")
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
def find_column_refs(
|
|
141
|
+
self, node: dict, changed_column: str, refs: list[str] | None = None, visited: set[str] | None = None
|
|
142
|
+
) -> list[str]:
|
|
143
|
+
"""Find references to changed columns in a node."""
|
|
144
|
+
if refs is None:
|
|
145
|
+
try:
|
|
146
|
+
sql = node.get("compiled_code", "select *")
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.warning(f"Could not get SQL for node {node}: {e}")
|
|
149
|
+
sql = "select *"
|
|
150
|
+
refs = [
|
|
151
|
+
projection
|
|
152
|
+
for select in parse_one(sql, dialect=self.dialect).find_all(exp.Select)
|
|
153
|
+
for projection in select.expressions
|
|
154
|
+
]
|
|
155
|
+
# If the model selects all columns, consider all changed columns as referenced
|
|
156
|
+
if set(refs) == {exp.Star()}:
|
|
157
|
+
return [changed_column]
|
|
158
|
+
|
|
159
|
+
if visited is None:
|
|
160
|
+
visited = set()
|
|
161
|
+
|
|
162
|
+
if changed_column in visited:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
visited.add(changed_column)
|
|
166
|
+
column_refs = []
|
|
167
|
+
|
|
168
|
+
for ref in refs:
|
|
169
|
+
if isinstance(ref, exp.Column) and ref.alias_or_name == changed_column:
|
|
170
|
+
column_refs.append(ref.alias_or_name)
|
|
171
|
+
elif isinstance(ref, exp.Alias):
|
|
172
|
+
for col in ref.this.find_all(exp.Column):
|
|
173
|
+
if col.alias_or_name == changed_column:
|
|
174
|
+
column_refs.append(ref.alias_or_name)
|
|
175
|
+
|
|
176
|
+
# If there are no new column references, return an empty set to avoid infinite recursion
|
|
177
|
+
if column_refs:
|
|
178
|
+
for column_ref in set(column_refs):
|
|
179
|
+
column_refs.extend(self.find_column_refs(node, column_ref, refs, visited))
|
|
180
|
+
|
|
181
|
+
# Recursively find column references
|
|
182
|
+
return column_refs
|
|
183
|
+
|
|
184
|
+
def search_in_graph(
|
|
185
|
+
self,
|
|
186
|
+
changed_node_id: str,
|
|
187
|
+
changed_columns: list[str],
|
|
188
|
+
visited: set[str] | None = None,
|
|
189
|
+
all: bool = False,
|
|
190
|
+
) -> list[str]:
|
|
191
|
+
"""Recursively search for models affected by column changes."""
|
|
192
|
+
if visited is None:
|
|
193
|
+
visited = set()
|
|
194
|
+
|
|
195
|
+
if changed_node_id in visited:
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
visited.add(changed_node_id)
|
|
199
|
+
affected = [changed_node_id]
|
|
200
|
+
|
|
201
|
+
for child_id in self.child_map.get(changed_node_id, []):
|
|
202
|
+
if child_id not in visited:
|
|
203
|
+
child_node = self.nodes[child_id]
|
|
204
|
+
# If no specific columns changed, all downstream nodes are affected
|
|
205
|
+
if all:
|
|
206
|
+
logger.info(
|
|
207
|
+
f"{child_node['resource_type']} {child_id} is affected by changes in {changed_node_id} (node changed entirely)"
|
|
208
|
+
)
|
|
209
|
+
affected.extend(self.search_in_graph(child_id, changed_columns, visited, all=all))
|
|
210
|
+
else:
|
|
211
|
+
# If the changed_model or child is not a model (e.g., a snapshot or seed), consider it affected
|
|
212
|
+
if (
|
|
213
|
+
self.nodes[changed_node_id]["resource_type"] != "model"
|
|
214
|
+
or child_node["resource_type"] != "model"
|
|
215
|
+
):
|
|
216
|
+
logger.info(
|
|
217
|
+
f"{child_node['resource_type']} {child_id} is affected by changes in {changed_node_id}"
|
|
218
|
+
)
|
|
219
|
+
affected.extend(self.search_in_graph(child_id, changed_columns, visited))
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# If the model selects all columns, consider all changed columns as referenced
|
|
223
|
+
column_refs = list(
|
|
224
|
+
{
|
|
225
|
+
column_ref
|
|
226
|
+
for changed_column in changed_columns
|
|
227
|
+
for column_ref in self.find_column_refs(child_node, changed_column)
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# If there are column references, consider the node affected
|
|
232
|
+
if column_refs:
|
|
233
|
+
logger.info(
|
|
234
|
+
f"{child_node['resource_type']} {child_id} is affected by changes in {changed_node_id}, columns: {column_refs}"
|
|
235
|
+
)
|
|
236
|
+
affected.extend(self.search_in_graph(child_id, column_refs, visited))
|
|
237
|
+
|
|
238
|
+
return list(set(affected))
|
|
239
|
+
|
|
240
|
+
def find_modified_nodes(self) -> list[str]:
|
|
241
|
+
"""Find all nodes affected by changes."""
|
|
242
|
+
logger.info("🧠 Starting smart model selection...")
|
|
243
|
+
|
|
244
|
+
if not self.modified_node_ids:
|
|
245
|
+
logger.info("No modified nodes found.")
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
self.compile_changed_nodes()
|
|
249
|
+
|
|
250
|
+
affected_nodes = set()
|
|
251
|
+
for changed_node_id in self.modified_node_ids:
|
|
252
|
+
changed_node = self.nodes[changed_node_id]
|
|
253
|
+
changed_columns = self.find_changed_columns(changed_node)
|
|
254
|
+
column_refs = list(
|
|
255
|
+
{
|
|
256
|
+
column_ref
|
|
257
|
+
for changed_column in changed_columns
|
|
258
|
+
for column_ref in self.find_column_refs(changed_node, changed_column)
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
# If no specific columns changed, treat it as a full change
|
|
262
|
+
all = len(changed_columns) == 0
|
|
263
|
+
if all:
|
|
264
|
+
logger.info(f"Changed node: {changed_node['name']}")
|
|
265
|
+
else:
|
|
266
|
+
logger.info(f"Changed model: {changed_node['name']} with changed columns: {column_refs}")
|
|
267
|
+
|
|
268
|
+
affected_nodes.update(self.search_in_graph(changed_node_id, column_refs, all=all))
|
|
269
|
+
|
|
270
|
+
return list([self.nodes[affected_node]["name"] for affected_node in affected_nodes])
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_logger(name: str = "dbt_cli"):
|
|
6
|
+
"""Return a configured logger instance."""
|
|
7
|
+
logger = logging.getLogger(name)
|
|
8
|
+
|
|
9
|
+
if not logger.handlers: # Prevent duplicate handlers
|
|
10
|
+
logger.setLevel(logging.INFO)
|
|
11
|
+
|
|
12
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
13
|
+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S")
|
|
14
|
+
handler.setFormatter(formatter)
|
|
15
|
+
logger.addHandler(handler)
|
|
16
|
+
|
|
17
|
+
return logger
|