diff --git a/src/snowflake/cli/_plugins/dcm/commands.py b/src/snowflake/cli/_plugins/dcm/commands.py index 176f20ccca..d67b14a2e2 100644 --- a/src/snowflake/cli/_plugins/dcm/commands.py +++ b/src/snowflake/cli/_plugins/dcm/commands.py @@ -27,11 +27,15 @@ from snowflake.cli._plugins.dcm.reporters import ( AnalyzeErrorsReporter, AnalyzeReporter, + DependenciesReporter, PlanReporter, RefreshReporter, TestReporter, ) from snowflake.cli._plugins.dcm.utils import ( + RENDERED_DEFINITIONS_FOLDER, + announce_compile_separator, + announce_rendered_definitions, clear_command_artifacts, mock_dcm_response, ) @@ -528,10 +532,11 @@ def raw_analyze( @app.command( + name="compile", requires_connection=True, hidden=not FeatureFlag.ENABLE_DCM_EARLY_ACCESS.is_enabled(), ) -def analyze_errors( +def compile_project( identifier: Optional[FQN] = optional_dcm_identifier, from_location: SecurePath = from_option, variables: Optional[List[str]] = variables_flag, @@ -540,9 +545,9 @@ def analyze_errors( **options, ): """ - Analyzes a DCM Project and prints a formatted list of errors found. + Compiles a DCM Project and prints a formatted list of errors found. """ - clear_command_artifacts("analyze-errors") + clear_command_artifacts("compile", folder_name=RENDERED_DEFINITIONS_FOLDER) context = _resolve_context_with_required_manifest(from_location, identifier, target) project_id = context.project_identifier @@ -562,14 +567,75 @@ def analyze_errors( from_stage=effective_stage, variables=variables, save_output=save_output, - command_name="analyze-errors", + command_name="compile", + output_folder_name=RENDERED_DEFINITIONS_FOLDER, ), phase_name="COMPILE", simulated_phases=["RENDER"], ) reporter = AnalyzeErrorsReporter(save_output=save_output) - return reporter.process(result) + if save_output: + announce_rendered_definitions() + try: + return reporter.process(result) + finally: + announce_compile_separator() + + +@app.command( + requires_connection=True, + hidden=not FeatureFlag.ENABLE_DCM_EARLY_ACCESS.is_enabled(), +) +def dependencies( + identifier: Optional[FQN] = optional_dcm_identifier, + from_location: SecurePath = from_option, + variables: Optional[List[str]] = variables_flag, + target: Optional[str] = target_option, + save_output: bool = save_output_option, + **options, +): + """ + Analyzes a DCM Project and generates a dependency diagram. + + The diagram is written as a Mermaid flowchart in a Markdown file that can + be opened in your IDE's Markdown preview to explore object dependencies. + """ + clear_command_artifacts("dependencies", folder_name=RENDERED_DEFINITIONS_FOLDER) + + context = _resolve_context_with_required_manifest(from_location, identifier, target) + project_id = context.project_identifier + + manager = DCMProjectManager() + tracker = DeployProgressTracker(conn=manager.connection, operation="compile") + with tracker.session(): + effective_stage = manager.sync_local_files( + project_identifier=project_id, + source_directory=str(from_location.path), + progress=tracker, + ) + result = tracker.run_loader_phase( + lambda: manager.raw_analyze( + project_identifier=project_id, + configuration=context.configuration, + from_stage=effective_stage, + variables=variables, + save_output=save_output, + command_name="dependencies", + output_folder_name=RENDERED_DEFINITIONS_FOLDER, + ), + phase_name="COMPILE", + simulated_phases=["RENDER"], + ) + + reporter = DependenciesReporter( + project_identifier=project_id, save_output=save_output + ) + try: + return reporter.process(result) + finally: + if save_output: + announce_rendered_definitions() @app.command(requires_connection=True) diff --git a/src/snowflake/cli/_plugins/dcm/manager.py b/src/snowflake/cli/_plugins/dcm/manager.py index 987f2dc52d..272ec5d1e0 100644 --- a/src/snowflake/cli/_plugins/dcm/manager.py +++ b/src/snowflake/cli/_plugins/dcm/manager.py @@ -143,6 +143,7 @@ def raw_analyze( variables: List[str] | None = None, save_output: bool = False, command_name: str = "raw-analyze", + output_folder_name: str | None = None, ): log.info( "Running DCM analyze manager operation (command_name=%s, project_identifier=%s, has_configuration=%s, variables_count=%d, save_output=%s).", @@ -158,7 +159,9 @@ def raw_analyze( if save_output: with collect_output( - project_identifier, command_name=command_name + project_identifier, + command_name=command_name, + folder_name=output_folder_name, ) as output_stage: query += f" OUTPUT_PATH {output_stage}" result = self.execute_query(query=query) diff --git a/src/snowflake/cli/_plugins/dcm/progress.py b/src/snowflake/cli/_plugins/dcm/progress.py index c43afbf23c..3dfeaaffcd 100644 --- a/src/snowflake/cli/_plugins/dcm/progress.py +++ b/src/snowflake/cli/_plugins/dcm/progress.py @@ -457,14 +457,14 @@ def _append_progress_bar(self, out: Text, progress: int) -> None: in_progress = 0 < filled < _BAR_WIDTH if in_progress: - out.append(_BAR_CELL * filled + _BAR_LEADING_EDGE, style="blue") + out.append(_BAR_CELL * filled + _BAR_LEADING_EDGE, style=styles.BLUE) out.append(_BAR_CELL * (_BAR_WIDTH - filled - 1), style="dim") elif filled == 0: out.append(_BAR_CELL * _BAR_WIDTH, style="dim") else: - out.append(_BAR_CELL * _BAR_WIDTH, style="blue") + out.append(_BAR_CELL * _BAR_WIDTH, style=styles.BLUE) - out.append(f" {progress:>3}%", style="blue") + out.append(f" {progress:>3}%", style=styles.BLUE) def _append_upload_details(self, out: Text) -> None: """Render the stage-creation message and folder counters indented @@ -509,7 +509,7 @@ def _render_phase_line(self, out: Text, phase: _Phase) -> None: # Blue matches the active-indicator color used by the # pip-style progress bar so all "this phase is in flight" # signals share the same hue. - out.append(_spinner_glyph(), style="blue") + out.append(_spinner_glyph(), style=styles.BLUE) out.append(duration_str + "\n", style="dim") else: # PENDING out.append(name_col + "·\n", style="dim") diff --git a/src/snowflake/cli/_plugins/dcm/reporters/__init__.py b/src/snowflake/cli/_plugins/dcm/reporters/__init__.py index 44cb47b0b9..652c86f99a 100644 --- a/src/snowflake/cli/_plugins/dcm/reporters/__init__.py +++ b/src/snowflake/cli/_plugins/dcm/reporters/__init__.py @@ -16,6 +16,7 @@ AnalyzeReporter, ) from snowflake.cli._plugins.dcm.reporters.base import Reporter +from snowflake.cli._plugins.dcm.reporters.dependencies import DependenciesReporter from snowflake.cli._plugins.dcm.reporters.plan import PlanReporter from snowflake.cli._plugins.dcm.reporters.refresh import RefreshReporter from snowflake.cli._plugins.dcm.reporters.test import TestReporter @@ -23,6 +24,7 @@ __all__ = [ "AnalyzeErrorsReporter", "AnalyzeReporter", + "DependenciesReporter", "Reporter", "PlanReporter", "RefreshReporter", diff --git a/src/snowflake/cli/_plugins/dcm/reporters/analyze.py b/src/snowflake/cli/_plugins/dcm/reporters/analyze.py index 7577a2f6e8..4bca10845c 100644 --- a/src/snowflake/cli/_plugins/dcm/reporters/analyze.py +++ b/src/snowflake/cli/_plugins/dcm/reporters/analyze.py @@ -322,7 +322,10 @@ class AnalyzeErrorsReporter(Reporter[_FileFindings]): def __init__(self, save_output: bool = False): super().__init__(save_output=save_output) - self.command_name = "analyze-errors" + self.command_name = "compile" + # ``compile`` prints its own "Rendered definitions saved to:" line, so + # suppress the generic "Artifacts saved to" step. + self.announce_save = False self._error_count = 0 self._warning_count = 0 self._info_count = 0 diff --git a/src/snowflake/cli/_plugins/dcm/reporters/base.py b/src/snowflake/cli/_plugins/dcm/reporters/base.py index e181927084..2df0ddc286 100644 --- a/src/snowflake/cli/_plugins/dcm/reporters/base.py +++ b/src/snowflake/cli/_plugins/dcm/reporters/base.py @@ -40,6 +40,10 @@ def __init__(self, save_output: bool = False) -> None: self.result_raw_data = None self.command_name = "" self.save_output = save_output + # When False, saving the raw response won't print the "Artifacts saved + # to" step (the file is still written). Commands that render their own + # output-location line (e.g. ``compile``) opt out of the default step. + self.announce_save = True @abstractmethod def extract_data(self, result_json: Dict[str, Any]) -> List[Dict[str, Any]]: @@ -77,7 +81,11 @@ def print_summary(self) -> None: def _try_save_response(self, result_json: Dict[str, Any]) -> None: """Save raw JSON response if save_output is enabled and raw data is available.""" if self.save_output: - save_command_response(self.command_name, result_json) + save_command_response( + self.command_name, + result_json, + announce=self.announce_save, + ) def process_payload(self, result_json: Dict[str, Any]) -> None: """Process already decoded response payload and print results.""" diff --git a/src/snowflake/cli/_plugins/dcm/reporters/dependencies.py b/src/snowflake/cli/_plugins/dcm/reporters/dependencies.py new file mode 100644 index 0000000000..495db315c1 --- /dev/null +++ b/src/snowflake/cli/_plugins/dcm/reporters/dependencies.py @@ -0,0 +1,408 @@ +# Copyright (c) 2024 Snowflake Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Generates a Mermaid dependency diagram from a DCM ``ANALYZE`` response. + +The same ``EXECUTE DCM PROJECT ... ANALYZE`` payload consumed by +:mod:`~snowflake.cli._plugins.dcm.reporters.analyze` also carries, for every +definition, its upstream ``dependencies`` and (for dynamic tables) a +``TARGET_LAG`` property. This module walks that payload, keeps only the +"data" objects worth graphing (tables, dynamic tables, views, functions, +procedures, tasks), and emits a Markdown file containing a Mermaid +``flowchart`` so the dependency graph can be opened in any IDE's Markdown +preview. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from typing import Any, Dict, Iterator, List, Optional, Tuple + +from rich.text import Text +from snowflake.cli._plugins.dcm import styles +from snowflake.cli._plugins.dcm.reporters.analyze import _files_from_response +from snowflake.cli._plugins.dcm.reporters.base import Reporter +from snowflake.cli._plugins.dcm.utils import OUTPUT_FOLDER +from snowflake.cli.api.console.console import cli_console +from snowflake.cli.api.identifiers import FQN +from snowflake.cli.api.secure_path import SecurePath + +log = logging.getLogger(__name__) + +DEFAULT_DEPENDENCIES_FILENAME = "dependencies.md" + +# A node's identity within the project: (database, schema, name, domain). +# ``database`` and ``schema`` may be ``None`` for account-level objects. +NodeKey = Tuple[Optional[str], Optional[str], Optional[str], Optional[str]] + + +@dataclass(frozen=True) +class _Category: + """Presentation metadata for one graphable kind of object.""" + + key: str # stable bucket key (drives declaration + class grouping order) + mermaid_class: str # Mermaid ``classDef`` name + label_prefix: str # e.g. "Table", "View" (unused for dynamic tables) + section_title: str # comment header in the generated diagram + + +# Declaration order matches the grouping used in the diagram. Anything whose +# ``refined_domain`` is absent from this map (databases, schemas, warehouses, +# roles, sequences, stages, file formats, alerts, ...) is treated as a +# structural object and excluded from both nodes and edges. +_CATEGORIES: List[_Category] = [ + _Category("table", "table", "Table", "Tables"), + _Category("dynamic_table", "dynTable", "Dynamic Table", "Dynamic Tables"), + _Category("view", "view", "View", "Views"), + _Category("function", "func", "Function", "Functions"), + _Category("procedure", "proc", "Procedure", "Procedures"), + _Category("task", "task", "Task", "Tasks"), +] + +_CATEGORY_BY_KEY: Dict[str, _Category] = {c.key: c for c in _CATEGORIES} + +# ``refined_domain`` value -> category key. +_DOMAIN_TO_CATEGORY: Dict[str, str] = { + "table": "table", + "dynamic_table": "dynamic_table", + "view": "view", + "function": "function", + "data_metric_function": "function", + "procedure": "procedure", + "task": "task", +} + +# Mermaid ``classDef`` styling, reproduced from the reference diagram so the +# generated file renders with consistent, readable colors. +_CLASS_DEFS: List[str] = [ + "classDef table fill:#3b82f6,stroke:#1d4ed8,stroke-width:2px,color:#fff", + "classDef dynTable fill:#10b981,stroke:#065f46,stroke-width:2px,color:#fff", + "classDef view fill:#8b5cf6,stroke:#5b21b6,stroke-width:2px,color:#fff", + "classDef func fill:#f59e0b,stroke:#b45309,stroke-width:2px,color:#000", + "classDef proc fill:#ef4444,stroke:#991b1b,stroke-width:2px,color:#fff", + "classDef task fill:#64748b,stroke:#334155,stroke-width:2px,color:#fff", +] + +_MERMAID_INIT = '%%{init: {"layout": "elk"}}%%' + + +@dataclass +class _Node: + key: NodeKey + mermaid_id: str + category: str + display_name: str + qualifier: str + lag: Optional[str] = None + + +@dataclass +class DependencyGraph: + """A resolved, render-ready dependency graph.""" + + # Category key -> nodes, preserving first-seen (file/definition) order. + nodes_by_category: Dict[str, List[_Node]] = field(default_factory=dict) + # Ordered, de-duplicated (source_mermaid_id, target_mermaid_id) edges. + edges: List[Tuple[str, str]] = field(default_factory=list) + + @property + def node_count(self) -> int: + return sum(len(nodes) for nodes in self.nodes_by_category.values()) + + @property + def edge_count(self) -> int: + return len(self.edges) + + +def _sanitize_mermaid_id(name: str) -> str: + """Turn an object name into a Mermaid-safe node id. + + Every character outside ``[0-9A-Za-z_]`` becomes ``_`` so that function / + procedure signatures (which contain parentheses, spaces and commas) yield + a valid identifier. + """ + return re.sub(r"[^0-9A-Za-z_]", "_", name) + + +def _node_key(id_dict: Dict[str, Any]) -> NodeKey: + return ( + id_dict.get("database"), + id_dict.get("schema"), + id_dict.get("name"), + id_dict.get("domain"), + ) + + +def _display_name(name: str) -> str: + """Strip a function/procedure signature, keeping the bare object name.""" + return name.split("(", 1)[0] + + +def _qualifier(id_dict: Dict[str, Any]) -> str: + parts = [ + part + for part in (id_dict.get("database"), id_dict.get("schema")) + if isinstance(part, str) and part + ] + return ".".join(parts) + + +def _target_lag(definition: Dict[str, Any]) -> Optional[str]: + for prop in definition.get("properties") or []: + if isinstance(prop, dict) and prop.get("name") == "TARGET_LAG": + value = prop.get("value") + if isinstance(value, str) and value: + return value + return None + + +class _MermaidIdAllocator: + """Hands out unique Mermaid ids, disambiguating sanitized collisions.""" + + def __init__(self) -> None: + self._used: set[str] = set() + + def allocate(self, name: str) -> str: + base = _sanitize_mermaid_id(name) or "node" + candidate = base + suffix = 2 + while candidate in self._used: + candidate = f"{base}_{suffix}" + suffix += 1 + self._used.add(candidate) + return candidate + + +def build_dependency_graph(files: List[Dict[str, Any]]) -> DependencyGraph: + """Build a :class:`DependencyGraph` from the ``files`` array of an analyze response.""" + graph = DependencyGraph() + allocator = _MermaidIdAllocator() + nodes_by_key: Dict[NodeKey, _Node] = {} + + # First pass: register every graphable definition as a node, preserving + # file/definition order within each category bucket. + for file_entry in files: + if not isinstance(file_entry, dict): + continue + for definition in file_entry.get("definitions") or []: + if not isinstance(definition, dict): + continue + refined = definition.get("refined_domain") + category = _DOMAIN_TO_CATEGORY.get(refined) if refined else None + if category is None: + continue + id_dict = definition.get("id") + if not isinstance(id_dict, dict) or not id_dict.get("name"): + continue + key = _node_key(id_dict) + if key in nodes_by_key: + continue + node = _Node( + key=key, + mermaid_id=allocator.allocate(id_dict["name"]), + category=category, + display_name=_display_name(id_dict["name"]), + qualifier=_qualifier(id_dict), + lag=_target_lag(definition) if category == "dynamic_table" else None, + ) + nodes_by_key[key] = node + graph.nodes_by_category.setdefault(category, []).append(node) + + # Second pass: resolve dependency edges. Only edges between graphable + # nodes are kept; dependencies on structural objects are dropped. + seen_edges: set[Tuple[str, str]] = set() + for file_entry in files: + if not isinstance(file_entry, dict): + continue + for definition in file_entry.get("definitions") or []: + if not isinstance(definition, dict): + continue + target = nodes_by_key.get(_node_key(definition.get("id") or {})) + if target is None: + continue + for dependency in definition.get("dependencies") or []: + if not isinstance(dependency, dict): + continue + source_id = dependency.get("source_id") + if not isinstance(source_id, dict): + continue + source = nodes_by_key.get(_node_key(source_id)) + if source is None or source.mermaid_id == target.mermaid_id: + continue + edge = (source.mermaid_id, target.mermaid_id) + if edge in seen_edges: + continue + seen_edges.add(edge) + graph.edges.append(edge) + + return graph + + +def _node_label(node: _Node) -> str: + name = node.display_name.replace('"', "'") + qualifier = node.qualifier.replace('"', "'") + if node.category == "dynamic_table": + lag = (node.lag or "unknown").replace('"', "'") + body = f"Dynamic Table [lag: {lag}]\\n{name}" + else: + prefix = _CATEGORY_BY_KEY[node.category].label_prefix + body = f"{prefix}: {name}" + if qualifier: + body = f"{body}\\n{qualifier}" + return body + + +def render_dependencies_markdown(graph: DependencyGraph, project_name: str) -> str: + """Render a full Markdown document embedding the Mermaid dependency diagram.""" + lines: List[str] = [ + f"# DCM Project dependencies for {project_name}", + "", + "> Auto-generated by `snow dcm dependencies`.", + "> Open this file in your IDE's Markdown preview to explore the graph.", + "", + "```mermaid", + _MERMAID_INIT, + "flowchart LR", + "", + ] + + if graph.node_count == 0: + lines.append(" %% No graphable objects found in this project.") + lines.append("```") + lines.append("") + return "\n".join(lines) + + for category in _CATEGORIES: + nodes = graph.nodes_by_category.get(category.key) + if not nodes: + continue + lines.append(f" %% ── {category.section_title} ──") + for node in nodes: + lines.append(f' {node.mermaid_id}("{_node_label(node)}")') + lines.append("") + + if graph.edges: + lines.append(" %% ── Edges ──") + for source, target in graph.edges: + lines.append(f" {source} --> {target}") + lines.append("") + + lines.append(" linkStyle default stroke:#475569,stroke-width:2.5px") + lines.append("") + for class_def in _CLASS_DEFS: + lines.append(f" {class_def}") + lines.append("") + + for category in _CATEGORIES: + nodes = graph.nodes_by_category.get(category.key) + if not nodes: + continue + ids = ",".join(node.mermaid_id for node in nodes) + lines.append(f" class {ids} {category.mermaid_class}") + + lines.append("```") + lines.append("") + return "\n".join(lines) + + +class DependenciesReporter(Reporter[Dict[str, Any]]): + """Writes a Mermaid dependency diagram and points the user at the file. + + The ``ANALYZE`` payload is identical to the one consumed by the + ``compile`` command; here we project it onto a dependency graph and + persist a Markdown file rather than printing findings. Analyze issues, if + any, do not fail this command — the dependency diagram is informational. + """ + + def __init__( + self, + project_identifier: Optional[FQN] = None, + output_path: Optional[SecurePath] = None, + save_output: bool = False, + ) -> None: + super().__init__(save_output=save_output) + self.command_name = "dependencies" + self._project_identifier = project_identifier + self._output_path = output_path or ( + SecurePath(OUTPUT_FOLDER) / DEFAULT_DEPENDENCIES_FILENAME + ) + self._graph: Optional[DependencyGraph] = None + self._written_path: Optional[str] = None + + def _project_name(self) -> str: + if self._project_identifier is not None: + return self._project_identifier.name + return "DCM Project" + + def extract_data(self, result_json: Dict[str, Any]) -> List[Dict[str, Any]]: + return _files_from_response(result_json) + + def parse_data(self, data: List[Dict[str, Any]]) -> Iterator[Dict[str, Any]]: + self._graph = build_dependency_graph(data) + # Nothing is streamed to ``print_renderables``; the graph lives on + # ``self`` and is rendered/written there. + return iter(()) + + def print_renderables(self, data: Iterator[Dict[str, Any]]) -> None: + for _ in data: + pass + graph = self._graph if self._graph is not None else DependencyGraph() + markdown = render_dependencies_markdown(graph, self._project_name()) + + output = self._output_path + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(markdown) + self._written_path = str(output.path.resolve()) + log.info( + "Wrote DCM dependency diagram (%d nodes, %d edges) to %s.", + graph.node_count, + graph.edge_count, + self._written_path, + ) + + def print_summary(self) -> None: + """Print summary without a leading blank line, one item per line.""" + renderables = self._generate_summary_renderables() + for renderable in renderables: + cli_console.styled_message(renderable.plain, style=renderable.style) + cli_console.styled_message("\n") + cli_console.styled_message("\n") + + def _generate_summary_renderables(self) -> List[Text]: + graph = self._graph if self._graph is not None else DependencyGraph() + if graph.node_count == 0: + return [ + Text( + "No objects were found to graph in this DCM Project.", + styles.WARNING_STYLE, + ) + ] + objects_word = "object" if graph.node_count == 1 else "objects" + deps_word = "dependency" if graph.edge_count == 1 else "dependencies" + return [ + Text( + f" Dependency diagram for {graph.node_count} {objects_word} " + f"and {graph.edge_count} {deps_word} written to:", + style="dim", + ), + Text(f" {self._written_path}", styles.FILE_PATH_STYLE), + Text( + " Open it in your IDE's Markdown preview to explore the graph.", + style="dim", + ), + ] + + def _is_success(self) -> bool: + return True diff --git a/src/snowflake/cli/_plugins/dcm/styles.py b/src/snowflake/cli/_plugins/dcm/styles.py index 08630ba0cc..afa91f39bf 100644 --- a/src/snowflake/cli/_plugins/dcm/styles.py +++ b/src/snowflake/cli/_plugins/dcm/styles.py @@ -13,13 +13,17 @@ # limitations under the License. from rich.style import Style -_COLOR_BLUE = "#a0a8fe" +# Single source of truth for the DCM "blue" hue. Use the terminal-default +# named color (not a fixed hex) so it respects the user's theme and stays +# identical everywhere it's used: progress spinner/bar, running phase, +# analyze INFO findings / file headers, refresh status, and unknown rows. +BLUE = "blue" DOMAIN_STYLE = Style(color="cyan") BOLD_STYLE = Style(bold=True) # Refresh -STATUS_STYLE = Style(color=_COLOR_BLUE) +STATUS_STYLE = Style(color=BLUE) REMOVED_STYLE = Style(color="red", italic=True) INSERTED_STYLE = Style(color="green", italic=True) @@ -28,18 +32,18 @@ FAIL_STYLE = Style(color="red") WARNING_STYLE = Style(color="yellow") # INFO-severity analyze findings: plain blue (distinct from bold-blue file headers). -INFO_STYLE = Style(color="blue") +INFO_STYLE = Style(color=BLUE) # Plan CREATE_STYLE = Style(color="green") ALTER_STYLE = Style(color="yellow") DROP_STYLE = Style(color="red") -UNKNOWN_STYLE = Style(color=_COLOR_BLUE) +UNKNOWN_STYLE = Style(color=BLUE) # Deploy progress phases PHASE_DONE_STYLE = Style(color="green", bold=True) -PHASE_RUNNING_STYLE = Style(color="blue", bold=True) +PHASE_RUNNING_STYLE = Style(color=BLUE, bold=True) PHASE_FAILED_STYLE = Style(color="red", bold=True) # Analyze (file/source path headers stand out in bold blue). -FILE_PATH_STYLE = Style(color="blue", bold=True) +FILE_PATH_STYLE = Style(color=BLUE, bold=True) diff --git a/src/snowflake/cli/_plugins/dcm/utils.py b/src/snowflake/cli/_plugins/dcm/utils.py index e53d379f84..64f288fc1d 100644 --- a/src/snowflake/cli/_plugins/dcm/utils.py +++ b/src/snowflake/cli/_plugins/dcm/utils.py @@ -19,6 +19,7 @@ from pathlib import Path from typing import Any, Dict, Generator +from rich.style import Style from snowflake.cli._plugins.stage.manager import StageManager from snowflake.cli.api.console.console import cli_console from snowflake.cli.api.constants import ObjectType @@ -30,10 +31,25 @@ log = logging.getLogger(__name__) OUTPUT_FOLDER = "out" - - -def clear_command_artifacts(command_name: str) -> None: - """Clear previous artifacts for the given command from the out/ directory.""" +# Local folder (under out/) where the backend-rendered project definitions are +# downloaded. Shared by the ``compile`` and ``dependencies`` commands so the +# rendered output always lands in the same, descriptively-named place. +RENDERED_DEFINITIONS_FOLDER = "rendered_definitions" +# Width of the trailing separator line printed at the end of a ``compile`` run. +COMPILE_SEPARATOR_WIDTH = 61 + + +def clear_command_artifacts( + command_name: str, + *, + folder_name: str | None = None, +) -> None: + """Clear previous artifacts for the given command from the out/ directory. + + ``folder_name`` defaults to ``command_name`` but can be set independently for + commands whose artifacts folder doesn't share the command name (e.g. + ``compile`` writes ``compile.json`` and ``rendered_definitions/``). + """ output_dir = SecurePath(OUTPUT_FOLDER) if not output_dir.exists(): return @@ -42,15 +58,55 @@ def clear_command_artifacts(command_name: str) -> None: if json_file.exists(): json_file.unlink() - artifacts_dir = output_dir / command_name + # Some commands (e.g. ``dependencies``) also emit a Markdown artifact. + markdown_file = output_dir / f"{command_name}.md" + if markdown_file.exists(): + markdown_file.unlink() + + artifacts_dir = output_dir / (folder_name or command_name) if artifacts_dir.exists(): artifacts_dir.rmdir(recursive=True) log.info("Cleared previous artifacts for command '%s'.", command_name) -def save_command_response(command_name: str, raw_data: Dict[str, Any] | str) -> None: - """Save raw JSON response to out/.json.""" +def announce_rendered_definitions() -> None: + """Print a label and a gray, clickable line to the rendered definitions folder. + + No-op when the folder doesn't exist (e.g. the backend produced no rendered + output). Used by the ``compile`` and ``dependencies`` commands after a + ``--save-output`` run to point the user at the downloaded definitions. + """ + folder = SecurePath(OUTPUT_FOLDER) / RENDERED_DEFINITIONS_FOLDER + if not folder.exists(): + return + abs_path = folder.path.resolve() + cli_console.styled_message("\n") + cli_console.styled_message("Rendered definitions saved to: ") + cli_console.styled_message("\n") + cli_console.styled_message( + f"{abs_path}", + style=Style(color="grey50", link=f"file://{abs_path}"), + ) + cli_console.styled_message("\n") + + +def announce_compile_separator() -> None: + """Print a separator line marking the end of a ``compile`` run.""" + cli_console.styled_message("=" * COMPILE_SEPARATOR_WIDTH) + cli_console.styled_message("\n") + + +def save_command_response( + command_name: str, + raw_data: Dict[str, Any] | str, + announce: bool = True, +) -> None: + """Save raw JSON response to out/.json. + + When ``announce`` is False the "Artifacts saved to" step is suppressed (the + file is still written) so callers can present their own output layout. + """ output_dir = SecurePath(OUTPUT_FOLDER) output_dir.mkdir(exist_ok=True) json_file = output_dir / f"{command_name}.json" @@ -67,20 +123,22 @@ def save_command_response(command_name: str, raw_data: Dict[str, Any] | str) -> command_name, json_file.resolve(), ) - cli_console.step(f"Artifacts saved to: {output_dir.path.resolve()}") + if announce: + cli_console.step(f"Artifacts saved to: {output_dir.path.resolve()}") @contextmanager def collect_output( - project_identifier: FQN, command_name: str + project_identifier: FQN, command_name: str, folder_name: str | None = None ) -> Generator[str, None, None]: """ Context manager for handling command output artifacts - creates temporary stage, - downloads files to out// folder after execution. + downloads files to out// folder after execution. Args: project_identifier: The DCM project identifier - command_name: Name of the command, used for the output subdirectory + command_name: Name of the command, used for logging + folder_name: Local output subdirectory under out/ (defaults to command_name) Yields: str: The effective output path to use in the DCM command @@ -99,7 +157,7 @@ def collect_output( effective_output_path = StagePath.from_stage_str( temp_stage_fqn.identifier ).joinpath("/outputs") - local_output_path = SecurePath(OUTPUT_FOLDER) / command_name + local_output_path = SecurePath(OUTPUT_FOLDER) / (folder_name or command_name) try: yield effective_output_path.absolute_path() @@ -155,8 +213,8 @@ def _load_debug_data(command_name: str, file_number: int): if command_name in ( "test", "refresh", - "analyze", - "analyze-errors", + "compile", + "dependencies", ): data = data[0] diff --git a/tests/dcm/test_commands.py b/tests/dcm/test_commands.py index a440c64383..8ee52112f7 100644 --- a/tests/dcm/test_commands.py +++ b/tests/dcm/test_commands.py @@ -1511,7 +1511,7 @@ def test_analyze_basic_no_errors( mock_manifest_load.return_value = _manifest_without_config() with project_directory("dcm_project"): - result = runner.invoke(["dcm", "analyze-errors", "fooBar"]) + result = runner.invoke(["dcm", "compile", "fooBar"]) assert result.exit_code == 0, result.output assert "Static analysis of DCM Project files found no errors." in result.output @@ -1522,7 +1522,8 @@ def test_analyze_basic_no_errors( from_stage="TMP_STAGE", variables=None, save_output=False, - command_name="analyze-errors", + command_name="compile", + output_folder_name="rendered_definitions", ) def test_analyze_with_errors_exits_with_formatted_output( @@ -1585,7 +1586,7 @@ def test_analyze_with_errors_exits_with_formatted_output( mock_manifest_load.return_value = _manifest_without_config() with project_directory("dcm_project"): - result = runner.invoke(["dcm", "analyze-errors", "fooBar"]) + result = runner.invoke(["dcm", "compile", "fooBar"]) assert result.exit_code == 1, result.output assert "sources/definitions/bad.sql" in result.output @@ -1617,9 +1618,7 @@ def test_analyze_with_variables( mock_manifest_load.return_value = _manifest_without_config() with project_directory("dcm_project"): - result = runner.invoke( - ["dcm", "analyze-errors", "fooBar", "-D", "key=value"] - ) + result = runner.invoke(["dcm", "compile", "fooBar", "-D", "key=value"]) assert result.exit_code == 0, result.output mock_dcm_manager().raw_analyze.assert_called_once_with( @@ -1628,7 +1627,8 @@ def test_analyze_with_variables( from_stage="TMP_STAGE", variables=["key=value"], save_output=False, - command_name="analyze-errors", + command_name="compile", + output_folder_name="rendered_definitions", ) def test_analyze_with_target( @@ -1662,7 +1662,7 @@ def test_analyze_with_target( ) with project_directory("dcm_project"): - result = runner.invoke(["dcm", "analyze-errors", "--target", "dev"]) + result = runner.invoke(["dcm", "compile", "--target", "dev"]) assert result.exit_code == 0, result.output mock_dcm_manager().raw_analyze.assert_called_once_with( @@ -1671,7 +1671,8 @@ def test_analyze_with_target( from_stage="TMP_STAGE", variables=None, save_output=False, - command_name="analyze-errors", + command_name="compile", + output_folder_name="rendered_definitions", ) def test_analyze_with_save_output( @@ -1691,7 +1692,7 @@ def test_analyze_with_save_output( mock_manifest_load.return_value = _manifest_without_config() with project_directory("dcm_project"): - result = runner.invoke(["dcm", "analyze-errors", "fooBar", "--save-output"]) + result = runner.invoke(["dcm", "compile", "fooBar", "--save-output"]) assert result.exit_code == 0, result.output mock_dcm_manager().raw_analyze.assert_called_once_with( @@ -1700,7 +1701,8 @@ def test_analyze_with_save_output( from_stage="TMP_STAGE", variables=None, save_output=True, - command_name="analyze-errors", + command_name="compile", + output_folder_name="rendered_definitions", ) def test_analyze_with_save_output_saves_response( @@ -1735,25 +1737,23 @@ def test_analyze_with_save_output_saves_response( mock_manifest_load.return_value = _manifest_without_config() with change_directory(tmp_path): - result = runner.invoke(["dcm", "analyze-errors", "fooBar", "--save-output"]) + result = runner.invoke(["dcm", "compile", "fooBar", "--save-output"]) assert result.exit_code == 0, result.output - _assert_json_dumped("analyze-errors", analyze_response, tmp_path) + _assert_json_dumped("compile", analyze_response, tmp_path) def test_analyze_from_stage_fails( self, mock_dcm_manager, runner, project_directory ): - result = runner.invoke( - ["dcm", "analyze-errors", "fooBar", "--from", "@my_stage"] - ) + result = runner.invoke(["dcm", "compile", "fooBar", "--from", "@my_stage"]) assert result.exit_code == 1, result.output assert "Stage paths are not supported" in result.output - def test_analyze_hidden_from_help_without_early_access(self, runner): - """The analyze command is gated on ENABLE_DCM_EARLY_ACCESS and hidden by default.""" + def test_compile_hidden_from_help_without_early_access(self, runner): + """The compile command is gated on ENABLE_DCM_EARLY_ACCESS and hidden by default.""" result = runner.invoke(["dcm", "--help"]) assert result.exit_code == 0 - assert "analyze" not in result.output + assert "compile" not in result.output @pytest.mark.parametrize("format_name", ["json", "json_ext"]) def test_analyze_with_json_formats_returns_response( @@ -1776,7 +1776,7 @@ def test_analyze_with_json_formats_returns_response( with project_directory("dcm_project"): result = runner.invoke( - ["dcm", "analyze-errors", "fooBar", "--format", format_name] + ["dcm", "compile", "fooBar", "--format", format_name] ) assert result.exit_code == 0, result.output @@ -1784,6 +1784,149 @@ def test_analyze_with_json_formats_returns_response( _assert_format_result(payload, json.loads(analyze_response), format_name) +def _dependencies_response(): + """Analyze response with a table feeding a dynamic table (one edge).""" + return json.dumps( + { + "files": [ + { + "source_path": "sources/definitions/raw.sql", + "definitions": [ + { + "id": { + "name": "CUSTOMER", + "schema": "RAW", + "database": "DB", + "domain": "TABLE", + }, + "dependencies": [], + "refined_domain": "table", + "issues": [], + } + ], + "issues": [], + }, + { + "source_path": "sources/definitions/analytics.sql", + "definitions": [ + { + "id": { + "name": "ENRICHED", + "schema": "ANALYTICS", + "database": "DB", + "domain": "TABLE", + }, + "dependencies": [ + { + "source_id": { + "name": "CUSTOMER", + "schema": "RAW", + "database": "DB", + "domain": "TABLE", + } + } + ], + "refined_domain": "dynamic_table", + "properties": [{"name": "TARGET_LAG", "value": "1 day"}], + "issues": [], + } + ], + "issues": [], + }, + ] + } + ) + + +class TestDCMDependencies: + def test_dependencies_writes_markdown_and_links_to_it( + self, + mock_dcm_manager, + mock_deploy_tracker, + mock_manifest_load, + runner, + mock_cursor, + mock_connect, + tmp_path, + ): + mock_dcm_manager().raw_analyze.return_value = mock_cursor( + rows=[(_dependencies_response(),)], columns=("result",) + ) + mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" + mock_manifest_load.return_value = _manifest_without_config() + + with change_directory(tmp_path): + result = runner.invoke(["dcm", "dependencies", "fooBar"]) + + assert result.exit_code == 0, result.output + + dependencies_file = tmp_path / "out" / "dependencies.md" + assert dependencies_file.exists() + content = dependencies_file.read_text() + + assert "# DCM Project dependencies for" in content + assert "flowchart LR" in content + assert "Table: CUSTOMER\\nDB.RAW" in content + assert "Dynamic Table [lag: 1 day]\\nENRICHED\\nDB.ANALYTICS" in content + assert "CUSTOMER --> ENRICHED" in content + + # The CLI points the user at the generated file. + assert "dependencies.md" in result.output + + mock_dcm_manager().raw_analyze.assert_called_once_with( + project_identifier=FQN.from_string("fooBar"), + configuration=None, + from_stage="TMP_STAGE", + variables=None, + save_output=False, + command_name="dependencies", + output_folder_name="rendered_definitions", + ) + + def test_dependencies_with_variables( + self, + mock_dcm_manager, + mock_deploy_tracker, + mock_manifest_load, + runner, + mock_cursor, + mock_connect, + tmp_path, + ): + mock_dcm_manager().raw_analyze.return_value = mock_cursor( + rows=[(_dependencies_response(),)], columns=("result",) + ) + mock_dcm_manager().sync_local_files.return_value = "TMP_STAGE" + mock_manifest_load.return_value = _manifest_without_config() + + with change_directory(tmp_path): + result = runner.invoke(["dcm", "dependencies", "fooBar", "-D", "key=value"]) + + assert result.exit_code == 0, result.output + mock_dcm_manager().raw_analyze.assert_called_once_with( + project_identifier=FQN.from_string("fooBar"), + configuration=None, + from_stage="TMP_STAGE", + variables=["key=value"], + save_output=False, + command_name="dependencies", + output_folder_name="rendered_definitions", + ) + + def test_dependencies_hidden_from_help_without_early_access(self, runner): + result = runner.invoke(["dcm", "--help"]) + assert result.exit_code == 0 + assert "dependencies" not in result.output + + def test_dependencies_from_stage_fails(self, mock_dcm_manager, runner, tmp_path): + with change_directory(tmp_path): + result = runner.invoke( + ["dcm", "dependencies", "fooBar", "--from", "@my_stage"] + ) + assert result.exit_code == 1, result.output + assert "Stage paths are not supported" in result.output + + class TestDCMList: def test_list_command_alias(self, mock_connect, runner): result = runner.invoke( diff --git a/tests/dcm/test_reporters/test_dependencies_reporter.py b/tests/dcm/test_reporters/test_dependencies_reporter.py new file mode 100644 index 0000000000..30efe6ecc5 --- /dev/null +++ b/tests/dcm/test_reporters/test_dependencies_reporter.py @@ -0,0 +1,230 @@ +# Copyright (c) 2024 Snowflake Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from snowflake.cli._plugins.dcm.reporters.dependencies import ( + DependenciesReporter, + _sanitize_mermaid_id, + build_dependency_graph, + render_dependencies_markdown, +) +from snowflake.cli.api.identifiers import FQN +from snowflake.cli.api.secure_path import SecurePath + +from tests.dcm.test_reporters.utils import FakeCursor, capture_reporter_output + + +def _id(name, domain, schema=None, database=None): + out = {"name": name, "domain": domain} + if schema is not None: + out["schema"] = schema + if database is not None: + out["database"] = database + return out + + +def _definition(id_dict, refined_domain, dependencies=None, properties=None): + definition = { + "id": id_dict, + "refined_domain": refined_domain, + "dependencies": [{"source_id": dep} for dep in (dependencies or [])], + "issues": [], + } + if properties is not None: + definition["properties"] = properties + return definition + + +_FUNCTION_NAME = "CALCULATE_PROFIT_MARGIN(REVENUE NUMBER, COST NUMBER)" + + +def _sample_files(): + """A representative project mixing graphable + structural objects.""" + db = _id("DB", "DATABASE") + raw = _id("RAW", "SCHEMA", database="DB") + analytics = _id("ANALYTICS", "SCHEMA", database="DB") + wh = _id("WH", "WAREHOUSE") + + customer = _id("CUSTOMER", "TABLE", schema="RAW", database="DB") + menu = _id("MENU", "TABLE", schema="RAW", database="DB") + enriched = _id("ENRICHED", "TABLE", schema="ANALYTICS", database="DB") + func = _id(_FUNCTION_NAME, "FUNCTION", schema="ANALYTICS", database="DB") + view = _id("V_DASH", "TABLE", schema="SERVE", database="DB") + proc = _id("SP_DO(X VARCHAR)", "PROCEDURE", schema="RAW", database="DB") + task1 = _id("T1", "TASK", schema="RAW", database="DB") + task2 = _id("T2", "TASK", schema="RAW", database="DB") + + return [ + { + "source_path": "sources/definitions/raw.sql", + "definitions": [ + _definition(db, "database"), + _definition(raw, "schema", [db]), + _definition(customer, "table", [db, raw]), + _definition(menu, "table", [db, raw]), + ], + "issues": [], + }, + { + "source_path": "sources/definitions/analytics.sql", + "definitions": [ + _definition(analytics, "schema", [db]), + _definition( + enriched, + "dynamic_table", + [db, analytics, raw, customer, menu, wh], + properties=[{"name": "TARGET_LAG", "value": "DOWNSTREAM"}], + ), + _definition(func, "function", [db, analytics]), + ], + "issues": [], + }, + { + "source_path": "sources/definitions/serve.sql", + "definitions": [ + _definition(view, "view", [db, analytics, enriched]), + ], + "issues": [], + }, + { + "source_path": "sources/definitions/ingest.sql", + "definitions": [ + _definition(proc, "procedure", [db, raw]), + _definition(task1, "task", [db, raw, wh]), + _definition(task2, "task", [db, raw, task1, wh]), + ], + "issues": [], + }, + ] + + +class TestSanitizeMermaidId: + def test_replaces_signature_punctuation_with_underscores(self): + assert ( + _sanitize_mermaid_id(_FUNCTION_NAME) + == "CALCULATE_PROFIT_MARGIN_REVENUE_NUMBER__COST_NUMBER_" + ) + + def test_plain_name_unchanged(self): + assert _sanitize_mermaid_id("CUSTOMER") == "CUSTOMER" + + +class TestBuildDependencyGraph: + def test_only_graphable_objects_become_nodes(self): + graph = build_dependency_graph(_sample_files()) + + # Structural objects (database/schema/warehouse) are excluded. + assert graph.node_count == 8 + assert set(graph.nodes_by_category) == { + "table", + "dynamic_table", + "view", + "function", + "procedure", + "task", + } + assert [n.display_name for n in graph.nodes_by_category["table"]] == [ + "CUSTOMER", + "MENU", + ] + + def test_node_order_follows_file_then_definition_order(self): + graph = build_dependency_graph(_sample_files()) + assert [n.display_name for n in graph.nodes_by_category["task"]] == ["T1", "T2"] + + def test_dynamic_table_captures_target_lag(self): + graph = build_dependency_graph(_sample_files()) + (enriched,) = graph.nodes_by_category["dynamic_table"] + assert enriched.lag == "DOWNSTREAM" + assert enriched.qualifier == "DB.ANALYTICS" + + def test_function_display_name_strips_signature(self): + graph = build_dependency_graph(_sample_files()) + (func,) = graph.nodes_by_category["function"] + assert func.display_name == "CALCULATE_PROFIT_MARGIN" + assert func.mermaid_id == "CALCULATE_PROFIT_MARGIN_REVENUE_NUMBER__COST_NUMBER_" + + def test_edges_only_between_graphable_nodes_in_target_order(self): + graph = build_dependency_graph(_sample_files()) + assert graph.edges == [ + ("CUSTOMER", "ENRICHED"), + ("MENU", "ENRICHED"), + ("ENRICHED", "V_DASH"), + ("T1", "T2"), + ] + + def test_empty_response_yields_empty_graph(self): + graph = build_dependency_graph([]) + assert graph.node_count == 0 + assert graph.edge_count == 0 + + +class TestRenderDependenciesMarkdown: + def test_contains_diagram_scaffold_and_nodes(self): + graph = build_dependency_graph(_sample_files()) + md = render_dependencies_markdown(graph, "MY_PROJECT") + + assert "# DCM Project dependencies for MY_PROJECT" in md + assert "```mermaid" in md + assert "flowchart LR" in md + assert "%% ── Tables ──" in md + assert 'CUSTOMER("Table: CUSTOMER\\nDB.RAW")' in md + assert ( + 'ENRICHED("Dynamic Table [lag: DOWNSTREAM]\\nENRICHED\\nDB.ANALYTICS")' + in md + ) + assert "CUSTOMER --> ENRICHED" in md + assert "classDef table" in md + assert "class CUSTOMER,MENU table" in md + + def test_empty_graph_notes_no_objects(self): + md = render_dependencies_markdown(build_dependency_graph([]), "EMPTY") + assert "No graphable objects found" in md + assert "```mermaid" in md + + +class TestDependenciesReporter: + def test_process_writes_file_and_reports_path(self, tmp_path): + output = SecurePath(tmp_path) / "dependencies.md" + reporter = DependenciesReporter( + project_identifier=FQN.from_string("MY_PROJECT"), + output_path=output, + ) + cursor = FakeCursor({"files": _sample_files()}) + + out = capture_reporter_output(reporter, cursor) + + assert output.path.exists() + content = output.path.read_text() + assert "flowchart LR" in content + assert "CUSTOMER --> ENRICHED" in content + + # Summary points at the written file and never fails the command. + assert str(output.path.resolve()) in out + assert "Dependency diagram" in out + + def test_process_creates_missing_output_directory(self, tmp_path): + output = SecurePath(tmp_path) / "nested" / "deep" / "dependencies.md" + reporter = DependenciesReporter(output_path=output) + cursor = FakeCursor({"files": _sample_files()}) + + capture_reporter_output(reporter, cursor) + assert output.path.exists() + + def test_process_with_no_graphable_objects(self, tmp_path): + output = SecurePath(tmp_path) / "dependencies.md" + reporter = DependenciesReporter(output_path=output) + cursor = FakeCursor({"files": []}) + + out = capture_reporter_output(reporter, cursor) + assert output.path.exists() + assert "No objects were found to graph" in out diff --git a/tests/dcm/test_utils.py b/tests/dcm/test_utils.py index 68d4c09158..aa3731df8d 100644 --- a/tests/dcm/test_utils.py +++ b/tests/dcm/test_utils.py @@ -3,6 +3,8 @@ from snowflake.cli._plugins.dcm.utils import ( OUTPUT_FOLDER, + RENDERED_DEFINITIONS_FOLDER, + announce_rendered_definitions, clear_command_artifacts, save_command_response, ) @@ -52,6 +54,43 @@ def test_noop_when_out_dir_missing(self, tmp_path): with change_directory(tmp_path): clear_command_artifacts("plan") + def test_clears_custom_folder_name(self, tmp_path): + with change_directory(tmp_path): + out_dir = tmp_path / OUTPUT_FOLDER + out_dir.mkdir() + compile_json = out_dir / "compile.json" + compile_json.write_text('{"old": "data"}') + rendered_dir = out_dir / RENDERED_DEFINITIONS_FOLDER + rendered_dir.mkdir() + (rendered_dir / "manifest.yml").write_text("name: x") + + clear_command_artifacts( + "compile", + folder_name=RENDERED_DEFINITIONS_FOLDER, + ) + + assert not compile_json.exists() + assert not rendered_dir.exists() + + +class TestAnnounceRenderedDefinitions: + def test_prints_path_when_folder_exists(self, tmp_path, capsys): + with change_directory(tmp_path): + rendered_dir = tmp_path / OUTPUT_FOLDER / RENDERED_DEFINITIONS_FOLDER + rendered_dir.mkdir(parents=True) + + announce_rendered_definitions() + + out = capsys.readouterr().out + assert "Rendered definitions saved to:" in out + assert RENDERED_DEFINITIONS_FOLDER in out + + def test_noop_when_folder_missing(self, tmp_path, capsys): + with change_directory(tmp_path): + announce_rendered_definitions() + + assert "Rendered definitions saved to:" not in capsys.readouterr().out + class TestSaveCommandResponse: def test_saves_json_file_from_string_payload(self, tmp_path): @@ -82,6 +121,12 @@ def test_creates_out_directory(self, tmp_path): assert (tmp_path / OUTPUT_FOLDER).exists() assert (tmp_path / OUTPUT_FOLDER / "refresh.json").exists() + def test_saves_compile_response_under_command_name(self, tmp_path): + with change_directory(tmp_path): + save_command_response("compile", {"files": []}) + + assert (tmp_path / OUTPUT_FOLDER / "compile.json").exists() + def test_handles_write_error_gracefully(self, tmp_path): with change_directory(tmp_path): with mock.patch(