Skip to content

Commit 7d19b8d

Browse files
committed
Add impact preview for any deployment
Unify dry and wet run paths Catch discrepancies between YAML changes and displayed output Display impacts in cli When dimension nodes lose columns, we should validate that no nodes that linked to that dimension or cubes that use that dimension are broken When metrics are broken, that should propagate down to the downstream cubes
1 parent 5e6a05f commit 7d19b8d

File tree

29 files changed

+6344
-2961
lines changed

29 files changed

+6344
-2961
lines changed

datajunction-clients/python/datajunction/cli.py

Lines changed: 21 additions & 225 deletions
Original file line numberDiff line numberDiff line change
@@ -15,225 +15,13 @@
1515
from rich.text import Text
1616

1717
from datajunction import DJBuilder, Project
18-
from datajunction.deployment import DeploymentService
18+
from datajunction.deployment import DeploymentService, display_impact_analysis
1919
from datajunction.exceptions import DJClientException, DJDeploymentFailure
2020

2121
logging.basicConfig(level=logging.INFO)
2222
logger = logging.getLogger(__name__)
2323

2424

25-
def display_impact_analysis(impact: dict, console: Console | None = None) -> None:
26-
"""
27-
Display deployment impact analysis with rich formatting.
28-
29-
Args:
30-
impact: The impact analysis response from the server
31-
console: Optional Rich console for output
32-
"""
33-
console = console or Console()
34-
namespace = impact.get("namespace", "unknown")
35-
36-
# Header
37-
console.print()
38-
console.print(
39-
f"[bold blue]📊 Impact Analysis for namespace:[/bold blue] [bold green]{namespace}[/bold green]",
40-
)
41-
console.print("━" * 60)
42-
console.print()
43-
44-
# Direct Changes Table
45-
changes = impact.get("changes", [])
46-
if changes:
47-
changes_table = Table(
48-
title="[bold]📝 Direct Changes[/bold]",
49-
box=box.ROUNDED,
50-
show_header=True,
51-
header_style="bold cyan",
52-
)
53-
changes_table.add_column("Operation", style="bold", width=10)
54-
changes_table.add_column("Node", style="magenta")
55-
changes_table.add_column("Type", style="dim", width=12)
56-
changes_table.add_column("Changed Fields", style="white")
57-
58-
operation_styles = {
59-
"create": ("🟢 Create", "green"),
60-
"update": ("🟡 Update", "yellow"),
61-
"delete": ("🔴 Delete", "red"),
62-
"noop": ("⚪ Skip", "dim"),
63-
}
64-
65-
for change in changes:
66-
op = change.get("operation", "unknown")
67-
op_display, op_color = operation_styles.get(op, (op.upper(), "white"))
68-
changed_fields = ", ".join(change.get("changed_fields", [])) or "-"
69-
changes_table.add_row(
70-
f"[{op_color}]{op_display}[/{op_color}]",
71-
change.get("name", ""),
72-
change.get("node_type", ""),
73-
changed_fields,
74-
)
75-
76-
console.print(changes_table)
77-
console.print()
78-
79-
# Summary for direct changes
80-
create_count = impact.get("create_count", 0)
81-
update_count = impact.get("update_count", 0)
82-
delete_count = impact.get("delete_count", 0)
83-
skip_count = impact.get("skip_count", 0)
84-
85-
summary_parts = []
86-
if create_count:
87-
summary_parts.append(
88-
f"[green]{create_count} create{'s' if create_count != 1 else ''}[/green]",
89-
)
90-
if update_count:
91-
summary_parts.append(
92-
f"[yellow]{update_count} update{'s' if update_count != 1 else ''}[/yellow]",
93-
)
94-
if delete_count:
95-
summary_parts.append(
96-
f"[red]{delete_count} delete{'s' if delete_count != 1 else ''}[/red]",
97-
)
98-
if skip_count:
99-
summary_parts.append(f"[dim]{skip_count} skipped[/dim]")
100-
101-
if summary_parts:
102-
console.print(f"[bold]Summary:[/bold] {', '.join(summary_parts)}")
103-
console.print()
104-
105-
# Column Changes (if any)
106-
column_changes_found = []
107-
for change in changes:
108-
for col_change in change.get("column_changes", []):
109-
column_changes_found.append(
110-
{
111-
"node": change.get("name"),
112-
**col_change,
113-
},
114-
)
115-
116-
if column_changes_found:
117-
col_table = Table(
118-
title="[bold]⚡ Column Changes[/bold]",
119-
box=box.ROUNDED,
120-
show_header=True,
121-
header_style="bold cyan",
122-
)
123-
col_table.add_column("Node", style="magenta")
124-
col_table.add_column("Change", style="bold", width=10)
125-
col_table.add_column("Details", style="white")
126-
127-
change_type_styles = {
128-
"added": ("🟢 Added", "green"),
129-
"removed": ("🔴 Removed", "red"),
130-
"type_changed": ("🟡 Type Changed", "yellow"),
131-
}
132-
133-
for col in column_changes_found:
134-
change_type = col.get("change_type", "unknown")
135-
display, color = change_type_styles.get(
136-
change_type,
137-
(change_type.upper(), "white"),
138-
)
139-
140-
if change_type == "type_changed":
141-
details = f"'{col.get('column')}': {col.get('old_type')}{col.get('new_type')}"
142-
elif change_type == "removed":
143-
details = f"Column '{col.get('column')}' removed"
144-
else:
145-
details = f"Column '{col.get('column')}' added"
146-
147-
col_table.add_row(
148-
col.get("node", ""),
149-
f"[{color}]{display}[/{color}]",
150-
details,
151-
)
152-
153-
console.print(col_table)
154-
console.print()
155-
156-
# Downstream Impact
157-
downstream_impacts = impact.get("downstream_impacts", [])
158-
if downstream_impacts:
159-
impact_table = Table(
160-
title="[bold]🔗 Downstream Impact[/bold]",
161-
box=box.ROUNDED,
162-
show_header=True,
163-
header_style="bold cyan",
164-
)
165-
impact_table.add_column("Node", style="magenta")
166-
impact_table.add_column("Impact", style="bold", width=18)
167-
impact_table.add_column("Reason", style="white")
168-
169-
impact_styles = {
170-
"will_invalidate": ("❌ Will Invalidate", "bold red"),
171-
"may_affect": ("⚠️ May Affect", "yellow"),
172-
"unchanged": ("✓ Unchanged", "dim"),
173-
}
174-
175-
for downstream in downstream_impacts:
176-
impact_type = downstream.get("impact_type", "unknown")
177-
display, style = impact_styles.get(
178-
impact_type,
179-
(impact_type.upper(), "white"),
180-
)
181-
impact_table.add_row(
182-
downstream.get("name", ""),
183-
f"[{style}]{display}[/{style}]",
184-
downstream.get("impact_reason", ""),
185-
)
186-
187-
console.print(impact_table)
188-
console.print()
189-
190-
# Downstream summary
191-
will_invalidate = impact.get("will_invalidate_count", 0)
192-
may_affect = impact.get("may_affect_count", 0)
193-
194-
impact_summary = []
195-
if will_invalidate:
196-
impact_summary.append(f"[red]{will_invalidate} will invalidate[/red]")
197-
if may_affect:
198-
impact_summary.append(f"[yellow]{may_affect} may be affected[/yellow]")
199-
200-
if impact_summary:
201-
console.print(
202-
f"[bold]Downstream Summary:[/bold] {', '.join(impact_summary)}",
203-
)
204-
console.print()
205-
else:
206-
console.print("[green]✅ No downstream impact detected.[/green]")
207-
console.print()
208-
209-
# Warnings
210-
warnings = impact.get("warnings", [])
211-
if warnings:
212-
console.print("[bold red]⚠️ Warnings:[/bold red]")
213-
for warning in warnings:
214-
console.print(f" [yellow]• {warning}[/yellow]")
215-
console.print()
216-
else:
217-
console.print("[green]✅ No warnings.[/green]")
218-
console.print()
219-
220-
# Final verdict
221-
has_issues = (
222-
impact.get("will_invalidate_count", 0) > 0
223-
or len(warnings) > 0
224-
or delete_count > 0
225-
)
226-
227-
if has_issues:
228-
console.print(
229-
"[yellow bold]⚠️ Review the warnings and downstream impacts before deploying.[/yellow bold]",
230-
)
231-
else:
232-
console.print("[green bold]✅ Ready to deploy![/green bold]")
233-
234-
console.print()
235-
236-
23725
class DJCLI:
23826
"""DJ command-line tool"""
23927

@@ -281,14 +69,24 @@ def dryrun(
28169
console = Console()
28270

28371
try:
284-
impact = self.deployment_service.get_impact(directory, namespace=namespace)
72+
with console.status(
73+
"[dim]Analyzing deployment impact...[/dim]",
74+
spinner="dots",
75+
):
76+
impact = self.deployment_service.get_impact(
77+
directory,
78+
namespace=namespace,
79+
)
28580

28681
if format == "json":
28782
print(json.dumps(impact, indent=2))
28883
else:
289-
console.print(f"[bold]Analyzing deployment from:[/bold] {directory}")
290-
console.print()
291-
display_impact_analysis(impact, console=console)
84+
display_impact_analysis(
85+
impact,
86+
console=console,
87+
server_url=self.builder_client.uri,
88+
source=DeploymentService._build_deployment_source(cwd=directory),
89+
)
29290
except DJClientException as exc:
29391
error_data = exc.args[0] if exc.args else str(exc)
29492
message = (
@@ -1357,8 +1155,7 @@ def dispatch_command(self, args, parser):
13571155
return
13581156
try:
13591157
self.push(args.directory, verbose=args.verbose, force=args.force)
1360-
except DJDeploymentFailure as exc:
1361-
logger.error("Deployment failed: %s", exc)
1158+
except DJDeploymentFailure:
13621159
raise SystemExit(1)
13631160
elif args.command == "push":
13641161
# Handle dry run first
@@ -1387,8 +1184,7 @@ def dispatch_command(self, args, parser):
13871184
verbose=args.verbose,
13881185
force=args.force,
13891186
)
1390-
except DJDeploymentFailure as exc:
1391-
logger.error("Deployment failed: %s", exc)
1187+
except DJDeploymentFailure:
13921188
raise SystemExit(1)
13931189
elif args.command == "generate-codeowners":
13941190
count = DeploymentService.build_codeowners(
@@ -1649,7 +1445,7 @@ def _setup_mcp_server(self, console: Console):
16491445

16501446
if not config_path:
16511447
console.print(
1652-
"\n[yellow]⚠️ Could not find Claude config directory[/yellow]",
1448+
"\n[yellow] Could not find Claude config directory[/yellow]",
16531449
)
16541450
console.print(
16551451
"[dim]Skipping MCP setup. You can manually add DJ MCP to your Claude config.[/dim]",
@@ -1662,7 +1458,7 @@ def _setup_mcp_server(self, console: Console):
16621458
# Find dj-mcp command
16631459
dj_mcp_path = shutil.which("dj-mcp")
16641460
if not dj_mcp_path:
1665-
console.print("[yellow]⚠️ dj-mcp command not found in PATH[/yellow]")
1461+
console.print("[yellow] dj-mcp command not found in PATH[/yellow]")
16661462
console.print(
16671463
"[dim]Make sure datajunction client is installed with MCP extras:[/dim]",
16681464
)
@@ -1680,7 +1476,7 @@ def _setup_mcp_server(self, console: Console):
16801476
config = json.load(f)
16811477
except json.JSONDecodeError:
16821478
console.print(
1683-
"[yellow]⚠️ Invalid JSON in Claude config, creating backup[/yellow]",
1479+
"[yellow] Invalid JSON in Claude config, creating backup[/yellow]",
16841480
)
16851481
backup_path = config_path.with_suffix(".json.backup")
16861482
shutil.copy(config_path, backup_path)
@@ -1716,7 +1512,7 @@ def _setup_mcp_server(self, console: Console):
17161512
console.print(" [dim]DJ_USERNAME: dj[/dim]")
17171513
console.print(" [dim]DJ_PASSWORD: *** (default)[/dim]\n")
17181514
console.print(
1719-
"[bold yellow]⚠️ Restart Claude Desktop/Code to load the MCP server[/bold yellow]",
1515+
"[bold yellow] Restart Claude Desktop/Code to load the MCP server[/bold yellow]",
17201516
)
17211517

17221518
def seed(self, type: str = "nodes"):

0 commit comments

Comments
 (0)