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 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