diff --git a/cognite_toolkit/_cdf_tk/commands/build_v2/build_v2.py b/cognite_toolkit/_cdf_tk/commands/build_v2/build_v2.py index acebd4d72a..ce9319d7c2 100644 --- a/cognite_toolkit/_cdf_tk/commands/build_v2/build_v2.py +++ b/cognite_toolkit/_cdf_tk/commands/build_v2/build_v2.py @@ -371,9 +371,7 @@ def _display_module_sources( summary_sections.append(ToolkitPanelSection(title="Issue details", content=issue_details_section_content)) border_style = {0: AuraColor.GREEN.rich, 1: AuraColor.AMBER.rich, 2: AuraColor.RED.rich}[border_color] - console.print( - ToolkitPanel(Group(*summary_sections), title="[bold]Loading modules[/]", border_style=border_style) - ) + console.print(ToolkitPanel(Group(*summary_sections), title="Loading modules", border_style=border_style)) if errors: console.print("\n") @@ -797,7 +795,7 @@ def _display_validation_plan(self, plan: list[ValidationStep], console: Console) console.print( ToolkitPanel( Group(*validation_sections), - title="[bold]Planning validation[/]", + title="Planning validation", border_style=border_style, ) ) @@ -971,7 +969,7 @@ def _display_build_summary( console.print( ToolkitPanel( "\n".join(summary_lines), - title=f"[bold]Built to directory {build_dir_display}[/]", + title=f"Built to directory {build_dir_display}", border_style=border_color, ) ) diff --git a/cognite_toolkit/_cdf_tk/commands/deploy.py b/cognite_toolkit/_cdf_tk/commands/deploy.py index 0cdf5a0d59..c5a7e4d13f 100644 --- a/cognite_toolkit/_cdf_tk/commands/deploy.py +++ b/cognite_toolkit/_cdf_tk/commands/deploy.py @@ -6,7 +6,6 @@ from cognite.client.exceptions import CogniteAPIError, CogniteDuplicatedError from rich import print from rich.markup import escape -from rich.panel import Panel from cognite_toolkit._cdf_tk.client import ToolkitClient from cognite_toolkit._cdf_tk.client._resource_base import T_Identifier, T_RequestResource, T_ResponseResource @@ -56,6 +55,7 @@ LowSeverityWarning, ToolkitDependenciesIncludedWarning, ) +from cognite_toolkit._cdf_tk.ui import AuraColor, ToolkitPanel from cognite_toolkit._cdf_tk.utils import humanize_collection, read_yaml_file from cognite_toolkit._cdf_tk.utils.auth import EnvironmentVariables @@ -168,16 +168,11 @@ def _order_loaders( @staticmethod def _start_message(build_dir: Path, dry_run: bool, env_vars: EnvironmentVariables) -> None: - environment_vars = "" - if not _RUNNING_IN_BROWSER: - environment_vars = f"\n\nConnected to {env_vars.as_string()}" verb = "Checking" if dry_run else "Deploying" - print( - Panel( - f"[bold]{verb}[/]resource files from {build_dir} directory.{environment_vars}", - expand=False, - ) - ) + content = f"[bold]{verb}[/] resource files from {build_dir} directory." + if not _RUNNING_IN_BROWSER: + content += f"\n\nConnected to {env_vars.as_string()}" + print(ToolkitPanel(content, title="Deploy")) def clean_all_resources( self, @@ -193,14 +188,12 @@ def clean_all_resources( verbose: bool, ) -> None: # Drop has to be done in the reverse order of deploy. - if drop and drop_data: - print(Panel("[bold] Cleaning resources as --drop and --drop-data are passed[/]")) - elif drop: - print(Panel("[bold] Cleaning resources as --drop is passed[/]")) - elif drop_data: - print(Panel("[bold] Cleaning resources as --drop-data is passed[/]")) - else: + if not (drop or drop_data): return None + flags = "--drop and --drop-data" if (drop and drop_data) else ("--drop" if drop else "--drop-data") + print( + ToolkitPanel(f"Cleaning resources as {flags} is passed", title="Clean", border_style=AuraColor.AMBER.rich) + ) for loader_cls in reversed(ordered_loaders): if not issubclass(loader_cls, ResourceIO): @@ -289,7 +282,7 @@ def deploy_all_resources( """ if verbose: - print(Panel("[bold]DEPLOYING resources...[/]")) + print(ToolkitPanel("[bold]Deploying resources...[/]", title="Deploy")) if ordered_loaders is None: selected_loaders = self._clean_command.get_selected_loaders(build_dir, set(), None) diff --git a/cognite_toolkit/_cdf_tk/commands/deploy_v2/command.py b/cognite_toolkit/_cdf_tk/commands/deploy_v2/command.py index 10f15371a2..ad35195785 100644 --- a/cognite_toolkit/_cdf_tk/commands/deploy_v2/command.py +++ b/cognite_toolkit/_cdf_tk/commands/deploy_v2/command.py @@ -10,11 +10,10 @@ import questionary from pydantic import ValidationError -from rich.console import Console +from rich.console import Console, Group, RenderableType from rich.markup import escape -from rich.panel import Panel +from rich.padding import Padding from rich.progress import Progress -from rich.table import Table from yaml import YAMLError from cognite_toolkit._cdf_tk.client import ToolkitClient @@ -52,8 +51,17 @@ catch_warnings, ) from cognite_toolkit._cdf_tk.tracker import Tracker -from cognite_toolkit._cdf_tk.utils import humanize_collection, sanitize_filename, to_diff +from cognite_toolkit._cdf_tk.ui import ( + AuraColor, + ToolkitPanel, + ToolkitPanelSection, + ToolkitTable, + diff_table, + hanging_indent, +) +from cognite_toolkit._cdf_tk.utils import humanize_collection, sanitize_filename from cognite_toolkit._cdf_tk.utils.auth import EnvironmentVariables +from cognite_toolkit._cdf_tk.utils.file import yaml_safe_dump from cognite_toolkit._version import __version__ Operation: TypeAlias = Literal["deploy", "clean"] @@ -72,10 +80,6 @@ class DeployOptions: environment_variables: dict[str, str | None] | None = None deployment_dir: Path | None = None - @property - def operation_noun(self) -> str: - return {"deploy": "deployment", "clean": "deletion"}[self.operation] - @dataclass class ResourceDirectory: @@ -213,12 +217,7 @@ def deploy( client = env_vars.get_client(is_strict_validation=build_dir.is_strict_validation) self._validate_cdf_project(build_dir, options.operation, options.cdf_project, env_vars.CDF_PROJECT) - self._display_startup(options.operation, build_dir.path, client.config.project, client.console) - self._display_read_dir(build_dir, client.console, options.verbose) - - plan = self.create_deployment_plan(build_dir) - - self._display_plan(plan, options.operation, options.operation_noun, client.console) + plan = self._display_setup(options.operation, build_dir, client.config.project, client.console, options.verbose) clean_result: Sequence[DeploymentResult] | None = None if options.drop and (options.operation == "clean" or not options.dry_run): @@ -233,7 +232,7 @@ def deploy( if clean_result is not None: self._merge_clean_results(results, clean_result) - self._display_results(results, options.operation, options.operation_noun, client.console, options.verbose) + self._display_results(results, options.operation, client.console, options.verbose) self._track_deployment_result(self.tracker, client, results, options.operation) if build_lineage and (raw_files := self._find_raw_tables(build_lineage)): @@ -324,78 +323,129 @@ def read_build_directory( cdf_project=cdf_project, ) - def _display_startup(self, operation: str, build_dir: Path, cdf_project: str, console: Console) -> None: - console.print( - Panel( - f"{operation.title()}ing {build_dir.as_posix()} directory:\n - Toolkit Version '{__version__!s}'\n" - f" - CDF project {cdf_project!r}", - expand=False, - ) + def _display_setup( + self, + operation: str, + build_dir: ReadBuildDirectory, + cdf_project: str, + console: Console, + verbose: bool, + ) -> list[DeploymentStep]: + startup_section = ToolkitPanelSection( + content=[ + f"Target project: {cdf_project!r}", + f"Toolkit version: {__version__!s}", + f"Build path: {build_dir.path.as_posix()}/", + ], ) - def _display_read_dir(self, build_dir: ReadBuildDirectory, console: Console, verbose: bool) -> None: - warnings = list(build_dir.create_warnings()) + read_dir_warnings = list(build_dir.create_warnings()) resource_dir_count = len(build_dir.resource_directories) skipped_dir_count = len(build_dir.skipped_directories) invalid_dir_count = len(build_dir.invalid_directories) - resource_file_count = sum( len(files) for dir_ in build_dir.resource_directories for files in dir_.files_by_crud.values() ) invalid_yaml_file_count = sum(len(dir_.invalid_files) for dir_ in build_dir.resource_directories) + has_issues = bool(read_dir_warnings or invalid_dir_count or invalid_yaml_file_count) - has_issues = bool(warnings or invalid_dir_count or invalid_yaml_file_count) - - summary_lines = [ + read_dir_summary = [ f"[green]✓[/] [bold]{resource_dir_count}[/] resource directories", f"[green]✓[/] [bold]{resource_file_count:,}[/] resource files", ] - if warnings: - summary_lines.append(f"[yellow]![/] [bold]{len(warnings)}[/] warnings during reading") + if read_dir_warnings: + read_dir_summary.append(f"[yellow]![/] [bold]{len(read_dir_warnings)}[/] warnings during reading") if skipped_dir_count: - summary_lines.append(f"[dim]○[/] [bold]{skipped_dir_count}[/] skipped directories") + read_dir_summary.append(f"[dim]○[/] [bold]{skipped_dir_count}[/] skipped directories") if invalid_dir_count: - summary_lines.append(f"[red]✗[/] [bold]{invalid_dir_count}[/] invalid directories") + read_dir_summary.append(f"[red]✗[/] [bold]{invalid_dir_count}[/] invalid directories") if invalid_yaml_file_count: - summary_lines.append(f"[red]✗[/] [bold]{invalid_yaml_file_count}[/] invalid yaml files") + read_dir_summary.append(f"[red]✗[/] [bold]{invalid_yaml_file_count}[/] invalid yaml files") - console.print( - Panel( - "\n".join(summary_lines), - title=f"[bold]Build directory ({build_dir.path.as_posix()})[/]", - border_style="yellow" if has_issues else "green", - expand=False, + read_dir_subsections: list[RenderableType] = [ToolkitPanelSection(content=read_dir_summary)] + if verbose: + if build_dir.skipped_directories: + read_dir_subsections.append( + ToolkitPanelSection( + title="Skipped directories (excluded by --include)", + content=[ + hanging_indent("○", dir_.directory.as_posix(), marker_style="dim") + for dir_ in build_dir.skipped_directories + ], + ) + ) + if build_dir.invalid_directories: + read_dir_subsections.append( + ToolkitPanelSection( + title="Invalid directories (will be skipped)", + content=[ + hanging_indent("✗", inv_dir.as_posix(), marker_style=AuraColor.RED.rich) + for inv_dir in build_dir.invalid_directories + ], + ) + ) + if invalid_yaml_file_count: + read_dir_subsections.append( + ToolkitPanelSection( + title="Invalid YAML files (will be skipped)", + content=[ + hanging_indent("✗", file.as_posix(), marker_style=AuraColor.RED.rich) + for dir_ in build_dir.resource_directories + for file in dir_.invalid_files + ], + ) + ) + elif skipped_dir_count or invalid_dir_count or invalid_yaml_file_count: + read_dir_subsections.append( + ToolkitPanelSection( + description=f"{HINT_LEAD_TEXT} Use --verbose to see details about skipped and invalid directories and files." + ) ) + read_dir_section = ToolkitPanelSection( + title="Processed build directory", + content=read_dir_subsections, ) - if warnings: - for warning in warnings: - self.warn(warning, console=console) - - if not verbose and (skipped_dir_count or invalid_dir_count or invalid_yaml_file_count): + try: + plan = self.create_deployment_plan(build_dir) + except Exception as e: console.print( - f"{HINT_LEAD_TEXT} Use --verbose flag to get more details about the skipped and invalid directories and files." + ToolkitPanel( + Group( + Padding(startup_section, (0, 0, 1, 0)), + read_dir_section, + ToolkitPanelSection( + description=f"[bold]Failed to create plan for {operation}:[/] {escape(str(e))}" + ), + ), + title=operation, + border_style=AuraColor.AMBER.rich, + ) + ) + raise + + if not plan: + plan_section = ToolkitPanelSection(description=f"[bold]No resources to {operation}.[/]") + else: + step_count = len(plan) + total_files = sum(len(step.files) for step in plan) + plan_section = ToolkitPanelSection( + title="Plan", + content=[ + f"[green]✓[/] [bold]{step_count}[/] resource types to {operation}", + f"[green]✓[/] [bold]{total_files}[/] resource files to {operation}", + ], ) - if verbose: - if build_dir.skipped_directories: - table = Table(title="Skipped Directories", expand=False, show_edge=False) - table.add_column("Directory", style="dim") - for dir_ in build_dir.skipped_directories: - table.add_row(dir_.directory.as_posix()) - console.print(table) - if build_dir.invalid_directories: - table = Table(title="Invalid Directories", expand=False, show_edge=False) - table.add_column("Directory", style="red") - for inv_dir in build_dir.invalid_directories: - table.add_row(inv_dir.as_posix()) - console.print(table) - if invalid_yaml_file_count: - table = Table(title="Invalid YAML Files", expand=False, show_edge=False) - table.add_column("File", style="red") - for dir_ in build_dir.resource_directories: - for file in dir_.invalid_files: - table.add_row(file.as_posix()) - console.print(table) + + border_style = AuraColor.AMBER.rich if (has_issues or not plan) else AuraColor.GREEN.rich + console.print( + ToolkitPanel( + Group(Padding(startup_section, (0, 0, 1, 0)), read_dir_section, plan_section), + title=f"Setting up {operation} operation", + border_style=border_style, + ) + ) + return plan def _validate_cdf_project( self, @@ -465,28 +515,6 @@ def create_deployment_plan(cls, read_dir: ReadBuildDirectory) -> list[Deployment ) return plan - @classmethod - def _display_plan(cls, plan: list[DeploymentStep], operation: str, operation_noun: str, console: Console) -> None: - if not plan: - console.print(f"[bold yellow]No resources to {operation}.[/]") - return - - step_count = len(plan) - total_files = sum(len(step.files) for step in plan) - - summary_lines = [ - f"[green]✓[/] [bold]{step_count}[/] resource types to {operation}", - f"[green]✓[/] [bold]{total_files}[/] resources to {operation}", - ] - console.print( - Panel( - "\n".join(summary_lines), - title=f"[bold]{operation_noun.title()} plan[/]", - border_style="green", - expand=False, - ) - ) - @classmethod def apply_plan( cls, client: ToolkitClient, plan: list[DeploymentStep], options: DeployOptions, is_delete: bool = False @@ -506,7 +534,7 @@ def apply_plan( console = client.console with Progress(console=console) as progress: total_files = sum(len(step.files) for step in plan) - task_id = progress.add_task(f"Starting {options.operation_noun}", total=total_files) + task_id = progress.add_task(f"Starting {options.operation}", total=total_files) for step in plan: crud = step.crud_cls.create_loader(client) resource_name = crud.display_name @@ -556,7 +584,8 @@ def apply_plan( results.append(result) progress.update(task_id, advance=len(step.files)) - progress.update(task_id, description=f"Finished {options.operation}ing.") + finished = "Finished dry-run." if options.dry_run else f"Finished {options.operation}ing." + progress.update(task_id, description=finished) return results @classmethod @@ -701,14 +730,15 @@ def _categorize_resources( resources.to_delete.append(identifier) resources.to_create.append(resource.request) if options.verbose: - diff_str = "\n".join(to_diff(cdf_dict, resource.raw_dict)) + old_lines = yaml_safe_dump(cdf_dict).splitlines() + new_lines = yaml_safe_dump(resource.raw_dict).splitlines() for sensitive in crud.sensitive_strings(resource.request): - diff_str = diff_str.replace(sensitive, "********") + old_lines = [line.replace(sensitive, "********") for line in old_lines] + new_lines = [line.replace(sensitive, "********") for line in new_lines] console.print( - Panel( - diff_str, + ToolkitPanel( + diff_table(old_lines, new_lines), title=f"{crud.display_name}: {identifier}", - expand=False, ) ) return resources @@ -884,17 +914,23 @@ def _merge_clean_results( @classmethod def _display_results( - cls, results: Sequence[DeploymentResult], operation: str, operation_noun: str, console: Console, verbose: bool + cls, results: Sequence[DeploymentResult], operation: str, console: Console, verbose: bool ) -> None: if not results: - console.print(f"No resources were {operation}ed.") + console.print( + ToolkitPanel( + f"No resources were {operation}ed.", + title=f"{operation.title()} summary", + ) + ) return is_dry_run = results[0].is_dry_run - title = f"{operation_noun.title()} Summary" + panel_title = f"{operation.title()} summary" if is_dry_run: - title += " (dry run)" - table = Table(title=title, show_lines=False) + panel_title += " [dim](dry run)[/]" + + table = ToolkitTable() table.add_column("Resource", style="cyan") if is_dry_run: table.add_column("Would create", justify="right", style="green") @@ -904,12 +940,11 @@ def _display_results( table.add_column("Created", justify="right", style="green") table.add_column("Updated", justify="right", style="yellow") table.add_column("Deleted", justify="right", style="red") - table.add_column("Unchanged", justify="right", style="dim") table.add_column("Skipped", justify="right", style="yellow") table.add_column("Total", justify="right", style="cyan") if is_dry_run: - table.add_column(f"Can {operation}", justify="right") + table.add_column("Able to deploy", justify="right") total = DeploymentResult( "All", @@ -932,11 +967,7 @@ def _display_results( str(result.total_count), ] if is_dry_run: - if result.is_missing_write_acl: - row.append("[red]No[/]") - else: - row.append("[green]Yes[/]") - + row.append("[red]No[/]" if result.is_missing_write_acl else "[green]Yes[/]") table.add_row(*row) total += result @@ -952,28 +983,38 @@ def _display_results( f"[bold]{total.total_count}[/]", ] if is_dry_run: - if total.is_missing_write_acl: - last_row.append("[red]No[/]") - else: - last_row.append("[green]Yes[/]") - + last_row.append("[red]No[/]" if total.is_missing_write_acl else "[green]Yes[/]") table.add_row(*last_row) - console.print(table) + sections: list[RenderableType] = [ToolkitPanelSection(content=[table.as_panel_detail()])] if total.skipped and not verbose: most_common = Counter(skip.code for skip in total.skipped).most_common(n=3) - console.print( - f"{HINT_LEAD_TEXT}A total of {total.skipped_count} resources were skipped during {operation_noun}. " - f"The most common reasons were: {', '.join(f'{code} ({count} occurrences)' for code, count in most_common)}. " - f"Use --verbose flag to get details about all skipped resources." + sections.append( + ToolkitPanelSection( + description=( + f"{HINT_LEAD_TEXT}A total of {total.skipped_count} resources were skipped during {operation}. " + f"The most common reasons were: {', '.join(f'{code} ({count} occurrences)' for code, count in most_common)}. " + f"Use --verbose to see all skipped resources." + ) + ) + ) + elif verbose and total.skipped: + sections.append( + ToolkitPanelSection( + title="Skipped resources", + content=[ + hanging_indent( + "○", + f"[bold]{skip.id}[/] {skip.source_file.as_posix()} [{skip.code}] {skip.reason}", + marker_style="dim", + ) + for skip in total.skipped + ], + ) ) - if verbose and total.skipped: - skipped_str = [ - f"{skip.id!s} in file {skip.source_file.as_posix()} | {skip.code} | {skip.reason}" - for skip in total.skipped - ] - console.print(Panel("\n".join(skipped_str), title="Skipped resources", expand=False)) + + console.print(ToolkitPanel(Group(*sections), title=panel_title)) @classmethod def _track_deployment_result( @@ -1054,14 +1095,22 @@ def _find_raw_tables(cls, build_lineage: BuildLineage) -> Mapping[RawTableSelect def _display_deprecation_warning(cls, raw_files: Mapping[RawTableSelector, list[Path]], console: Console) -> None: raw_table_count = len(raw_files) file_count = sum(len(files) for files in raw_files.values()) + plural_t = "" if raw_table_count == 1 else "s" + plural_f = "" if file_count == 1 else "s" console.print( - Panel( - f"[yellow]Deprecation Warning[/]\n\n" - f"You are deploying {raw_table_count} raw table{'' if raw_table_count == 1 else 's'} based on {file_count} file{'' if file_count == 1 else 's'}.\n\n" - f"Support for deploying raw tables through the deploy command will be removed in a future release. " - f"Please migrate your raw tables to use the new data plugin. See the documentation for more details.", - title="Deprecation Warning", - border_style="yellow", - expand=False, + ToolkitPanel( + Group( + ToolkitPanelSection( + description=f"[bold]{raw_table_count}[/] raw table{plural_t} from [bold]{file_count}[/] file{plural_f}." + ), + ToolkitPanelSection( + description=( + "Support for deploying raw tables through the deploy command will be removed in a future release. " + "Please migrate your raw tables to use the new data plugin. See the documentation for more details." + ) + ), + ), + title="Deprecation warning", + border_style=AuraColor.AMBER.rich, ) ) diff --git a/cognite_toolkit/_cdf_tk/ui.py b/cognite_toolkit/_cdf_tk/ui.py index 282d71edd9..e298138f91 100644 --- a/cognite_toolkit/_cdf_tk/ui.py +++ b/cognite_toolkit/_cdf_tk/ui.py @@ -1,4 +1,5 @@ from collections.abc import Sequence +from difflib import SequenceMatcher from enum import Enum from typing import Any, ClassVar, Literal @@ -11,7 +12,15 @@ from rich.table import Table from rich.text import Text -__all__ = ["QUESTIONARY_STYLE", "AuraColor", "ToolkitPanel", "ToolkitPanelSection", "ToolkitTable", "hanging_indent"] +__all__ = [ + "QUESTIONARY_STYLE", + "AuraColor", + "ToolkitPanel", + "ToolkitPanelSection", + "ToolkitTable", + "diff_table", + "hanging_indent", +] # https://cognitedata.github.io/aura/primitives/colors @@ -41,14 +50,18 @@ def __init__( renderable: RenderableType, box: rich_box.Box = rich_box.ROUNDED, *, + title: str | Text | None = None, title_align: Literal["left", "center", "right"] = "left", subtitle_align: Literal["left", "center", "right"] = "left", padding: int | tuple[int] | tuple[int, int] | tuple[int, int, int, int] = (1, 2), **kwargs: Any, ) -> None: + if isinstance(title, str): + title = Text.from_markup(title, style="bold") super().__init__( renderable, box, + title=title, title_align=title_align, subtitle_align=subtitle_align, padding=padding, @@ -120,6 +133,94 @@ def as_panel_detail(self) -> RenderableType: return Padding(self, (1, 0, 1, 2)) +def _yaml_key(line: str) -> str | None: + stripped = line.lstrip() + if ":" in stripped: + key = stripped.split(":")[0].strip() + return key or None + return None + + +def _highlight_diff(old_str: str, new_str: str) -> tuple[str, str]: + """Return (new_highlighted, old_highlighted) with changed character spans wrapped in [bold].""" + matcher = SequenceMatcher(None, old_str, new_str, autojunk=False) + old_parts: list[str] = [] + new_parts: list[str] = [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + old_parts.append(old_str[i1:i2]) + new_parts.append(new_str[j1:j2]) + elif tag == "delete": + old_parts.append(f"[bold]{old_str[i1:i2]}[/bold]") + elif tag == "insert": + new_parts.append(f"[bold]{new_str[j1:j2]}[/bold]") + elif tag == "replace": + old_parts.append(f"[bold]{old_str[i1:i2]}[/bold]") + new_parts.append(f"[bold]{new_str[j1:j2]}[/bold]") + return "".join(new_parts), "".join(old_parts) + + +def diff_table(old_lines: list[str], new_lines: list[str], context: int = 2) -> RenderableType: + """Side-by-side diff table comparing old (left) and new (right) lines. + + Equal regions larger than 2*context+1 lines are collapsed to a '…' separator. + Deleted lines are shown in red on the left, inserted lines in green on the right. + """ + matcher = SequenceMatcher(None, old_lines, new_lines, autojunk=False) + + table = Table( + box=rich_box.SIMPLE, + show_edge=False, + padding=(0, 1), + expand=False, + highlight=False, + show_header=True, + ) + table.add_column(f"[{AuraColor.GREEN.rich}]Local (desired)[/]", overflow="fold", ratio=1, no_wrap=False) + table.add_column(f"[{AuraColor.RED.rich}]CDF (current)[/]", overflow="fold", ratio=1, no_wrap=False) + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + block = old_lines[i1:i2] + if len(block) <= 2 * context + 1: + for line in block: + table.add_row(f"[dim]{line}[/]", f"[dim]{line}[/]") + else: + for line in block[:context]: + table.add_row(f"[dim]{line}[/]", f"[dim]{line}[/]") + table.add_row( + f"[{AuraColor.MOUNTAIN.rich}]…[/]", + f"[{AuraColor.MOUNTAIN.rich}]…[/]", + ) + for line in block[-context:]: + table.add_row(f"[dim]{line}[/]", f"[dim]{line}[/]") + elif tag == "delete": + for line in old_lines[i1:i2]: + table.add_row("", f"[{AuraColor.RED.rich}]{line}[/]") + elif tag == "insert": + for line in new_lines[j1:j2]: + table.add_row(f"[{AuraColor.GREEN.rich}]{line}[/]", "") + elif tag == "replace": + old_block = old_lines[i1:i2] + new_block = new_lines[j1:j2] + old_by_key = {_yaml_key(line): line for line in old_block if _yaml_key(line)} + seen_old_keys: set[str] = set() + for line in new_block: + key = _yaml_key(line) + if key and key in old_by_key: + seen_old_keys.add(key) + local_hl, cdf_hl = _highlight_diff(old_by_key[key], line) + table.add_row(f"[{AuraColor.GREEN.rich}]{local_hl}[/]", f"[{AuraColor.RED.rich}]{cdf_hl}[/]") + else: + table.add_row(f"[{AuraColor.GREEN.rich}]{line}[/]", "") + for line in old_block: + key = _yaml_key(line) + if key not in seen_old_keys: + table.add_row("", f"[{AuraColor.RED.rich}]{line}[/]") + + return table + + QUESTIONARY_STYLE = questionary.Style( [ ("qmark", AuraColor.FJORD.fg), # token in front of the question diff --git a/tests/test_unit/test_cdf_tk/test_commands/test_deployv2/test_diff_table.py b/tests/test_unit/test_cdf_tk/test_commands/test_deployv2/test_diff_table.py new file mode 100644 index 0000000000..ce9aa4839e --- /dev/null +++ b/tests/test_unit/test_cdf_tk/test_commands/test_deployv2/test_diff_table.py @@ -0,0 +1,57 @@ +"""Visual tests for diff_table — run with `pytest -s` to see rendered output.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from rich.console import Console + +from cognite_toolkit._cdf_tk.ui import ToolkitPanel, diff_table +from cognite_toolkit._cdf_tk.utils.file import yaml_safe_dump + +_FUNCTION_YAML = ( + Path(__file__).resolve().parents[5] / "cognite_toolkit/modules/operator/functions/toolkit.Function.yaml" +) + + +def _load() -> dict: + return yaml.safe_load(_FUNCTION_YAML.read_text()) + + +def _render(title: str, old: dict, new: dict, console: Console) -> None: + old_lines = yaml_safe_dump(old).splitlines() + new_lines = yaml_safe_dump(new).splitlines() + console.print(ToolkitPanel(diff_table(old_lines, new_lines), title=title)) + + +@pytest.fixture +def console() -> Console: + return Console(width=120) + + +class TestDiffTableVariations: + def test_field_value_changed(self, console: Console) -> None: + local = _load() + cdf = {**local, "runtime": "py311", "cpu": 1.0} + _render("Field value changed (runtime + cpu)", local, cdf, console) + + def test_field_added_locally(self, console: Console) -> None: + local = _load() + cdf = {k: v for k, v in local.items() if k not in {"envVars", "memory"}} + _render("Fields added locally (envVars + memory missing in CDF)", local, cdf, console) + + def test_field_removed_locally(self, console: Console) -> None: + local = {k: v for k, v in _load().items() if k != "description"} + cdf = _load() + _render("Field removed locally (description in CDF only)", local, cdf, console) + + def test_minimal_cdf_state(self, console: Console) -> None: + local = _load() + cdf = {"externalId": local["externalId"], "status": "Failed"} + _render("Minimal CDF state (failed function, full local definition)", local, cdf, console) + + def test_no_diff(self, console: Console) -> None: + resource = _load() + _render("No diff (identical)", resource, resource, console)