Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cognite_toolkit/_cdf_tk/apps/_core_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
BuildV2Command,
CleanCommand,
DeployCommand,
DeployDiffFormat,
DeployOptions,
DeployV2Command,
)
Expand Down Expand Up @@ -520,6 +521,14 @@ def deploy_v2(
help="Turn on to get more verbose output when running the command",
),
] = False,
diff: Annotated[
DeployDiffFormat | None,
typer.Option(
"--diff",
help="When set to 'human', show a ToolkitPanel summary and a side-by-side line comparison of "
"CDF (API) dump vs build YAML for each resource that would change.",
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New feature, alpha flag?

] = None,
) -> None:
"""Deploys the configuration files in the build directory to the CDF project."""
if drop:
Expand Down Expand Up @@ -548,6 +557,7 @@ def deploy_v2(
include=include,
force_update=force_update,
verbose=verbose,
diff=diff,
environment_variables=env_vars.dump(),
deployment_dir=deploy_dir,
),
Expand Down
3 changes: 2 additions & 1 deletion cognite_toolkit/_cdf_tk/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .build_v2.build_v2 import BuildV2Command
from .clean import CleanCommand
from .deploy import DeployCommand
from .deploy_v2.command import DeploymentStep, DeployOptions, DeployV2Command
from .deploy_v2.command import DeployDiffFormat, DeploymentStep, DeployOptions, DeployV2Command
from .dump_resource import DumpResourceCommand
from .entity_matching import EntityMatchingCommand
from .functions import FunctionsCommand
Expand All @@ -31,6 +31,7 @@
"BuildV2Command",
"CleanCommand",
"DeployCommand",
"DeployDiffFormat",
"DeployOptions",
"DeployV2Command",
"DeploymentStep",
Expand Down
25 changes: 22 additions & 3 deletions cognite_toolkit/_cdf_tk/commands/deploy_v2/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
validate_soft_delete_capacity,
)
from cognite_toolkit._cdf_tk.commands.build_v2.data_classes import BuildLineage
from cognite_toolkit._cdf_tk.commands.deploy_v2.diff_display import DeployDiffFormat, render_deploy_human_diff
from cognite_toolkit._cdf_tk.constants import HINT_LEAD_TEXT
from cognite_toolkit._cdf_tk.data_classes._tracking_info import DeploymentTracking
from cognite_toolkit._cdf_tk.dataio.selectors import RawTableSelector, SelectedTable
Expand Down Expand Up @@ -88,6 +89,7 @@ class DeployOptions:
drop_data: bool = False
environment_variables: dict[str, str | None] | None = None
deployment_dir: Path | None = None
diff: DeployDiffFormat | None = None


@dataclass
Expand Down Expand Up @@ -379,7 +381,7 @@ def _display_setup(
if invalid_yaml_file_count:
read_dir_summary.append(f"[red]✗[/] [bold]{invalid_yaml_file_count}[/] invalid yaml files")

read_dir_subsections: list[RenderableType] = [ToolkitPanelSection(content=read_dir_summary)]
read_dir_subsections: list[RenderableType] = [*read_dir_summary]
if verbose:
if build_dir.skipped_directories:
read_dir_subsections.append(
Expand Down Expand Up @@ -874,7 +876,19 @@ def _categorize_resources(
else:
resources.to_delete.append(identifier)
resources.to_create.append(resource.request)
if options.verbose:
if options.diff == DeployDiffFormat.human:
console.print(
render_deploy_human_diff(
resource_name=crud.display_name,
identifier=identifier,
source_file=resource.source_files[0],
cdf_dict=cdf_dict,
yaml_dict=resource.raw_dict,
sensitive_strings=crud.sensitive_strings(resource.request),
cdf_project=crud.client.config.project,
)
)
elif options.verbose:
diff_str = "\n".join(to_diff(cdf_dict, resource.raw_dict))
for sensitive in crud.sensitive_strings(resource.request):
diff_str = diff_str.replace(sensitive, "********")
Expand Down Expand Up @@ -1100,7 +1114,12 @@ def _display_results(
skipped=[],
is_missing_write_acl=False,
)
for result in results:
display_results = sorted(
results,
key=lambda r: (r.created_count, r.updated_count, r.deleted_count, r.unchanged_count),
reverse=True,
)
for result in display_results:
row = [
result.resource_name,
str(result.created_count),
Expand Down
195 changes: 195 additions & 0 deletions cognite_toolkit/_cdf_tk/commands/deploy_v2/diff_display.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Human-readable deploy drift views (YAML vs CDF API)."""

from collections.abc import Iterable, Iterator, Sequence
from difflib import SequenceMatcher
from enum import Enum
from pathlib import Path
from typing import Any

from rich.console import Group
from rich.text import Text

from cognite_toolkit._cdf_tk.ui import AuraColor, ToolkitPanel, ToolkitPanelSection, ToolkitTable
from cognite_toolkit._cdf_tk.utils.file import yaml_safe_dump


class DeployDiffFormat(str, Enum):
human = "human"


# Rich Padding (top, right, bottom, left): one consistent body inset for every diff block.
_SECTION_BODY_PADDING = (0, 0, 1, 2)


def _align_nested_dict_pair_for_yaml(cdf: Any, build: Any) -> tuple[Any, Any]:
"""Return copies of ``cdf`` / ``build`` with identical dict key order for paired dumps.

Order at each dict level: keys from **build** (insertion order), then keys only in **CDF**
(insertion order). Nested dict values are aligned the same way so ``yaml_safe_dump(...,
sort_keys=False)`` produces line-aligned text without global alphabetical sorting.

Non-dict values and mismatched types are left unchanged; lists are not reordered.
Empty ``{}`` is used only when a dict exists on one side and the **key** is absent on the other,
so scalar-vs-dict mismatches are not coerced to ``{}``.
"""
Comment on lines +24 to +34
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The docstring for _align_nested_dict_pair_for_yaml does not follow the required Args: and Returns: format specified in the repository style guide. Please update it to adhere to the standard format.

def _align_nested_dict_pair_for_yaml(cdf: Any, build: Any) -> tuple[Any, Any]:
    """
    Return copies of ``cdf`` / ``build`` with identical dict key order for paired dumps.

    Order at each dict level: keys from **build** (insertion order), then keys only in **CDF**
    (insertion order). Nested dict values are aligned the same way so ``yaml_safe_dump(...,
    sort_keys=False)`` produces line-aligned text without global alphabetical sorting.

    Non-dict values and mismatched types are left unchanged; lists are not reordered.
    Empty ``{}`` is used only when a dict exists on one side and the **key** is absent on the other,
    so scalar-vs-dict mismatches are not coerced to ``{}``.

    Args:
        cdf: The CDF resource data.
        build: The local build resource data.

    Returns:
        tuple[Any, Any]: A tuple containing the aligned (cdf, build) data.
    """
References
  1. Use concise docstrings with Args/Returns format. (link)

if isinstance(cdf, dict) and isinstance(build, dict):
keys = list(dict.fromkeys(list(build.keys()) + [k for k in cdf if k not in build]))
out_cdf: dict[str, Any] = {}
out_build: dict[str, Any] = {}
for k in keys:
cv = cdf.get(k)
bv = build.get(k)
if isinstance(cv, dict) and isinstance(bv, dict):
out_cdf[k], out_build[k] = _align_nested_dict_pair_for_yaml(cv, bv)
elif isinstance(cv, dict) and k not in build:
out_cdf[k], out_build[k] = _align_nested_dict_pair_for_yaml(cv, {})
elif isinstance(bv, dict) and k not in cdf:
out_cdf[k], out_build[k] = _align_nested_dict_pair_for_yaml({}, bv)
else:
out_cdf[k] = cv
out_build[k] = bv
return out_cdf, out_build
return cdf, build


def _sanitize(text: str, sensitive_strings: Iterable[str]) -> str:
for sensitive in sensitive_strings:
text = text.replace(sensitive, "********")
return text


def _summarize_opcodes(opcodes: Sequence[tuple[str, int, int, int, int]]) -> tuple[int, int, int, int]:
"""Returns counts of: delete-only lines, insert-only lines, replace blocks, equal lines (excluding collapsed)."""
delete_lines = 0
insert_lines = 0
replace_blocks = 0
equal_lines = 0
for tag, i1, i2, j1, j2 in opcodes:
if tag == "delete":
delete_lines += i2 - i1
elif tag == "insert":
insert_lines += j2 - j1
elif tag == "replace":
replace_blocks += 1
elif tag == "equal":
equal_lines += i2 - i1
return delete_lines, insert_lines, replace_blocks, equal_lines


def _side_by_side_rows(
cdf_lines: list[str],
yaml_lines: list[str],
*,
equal_context: int = 3,
equal_collapse_at: int = 12,
) -> Iterator[tuple[Text, Text]]:
"""Yield (CDF column cell, local build column cell); the table swaps them to show build left."""
matcher = SequenceMatcher(None, cdf_lines, yaml_lines)
opcodes = matcher.get_opcodes()

for tag, i1, i2, j1, j2 in opcodes:
if tag == "equal":
span = i2 - i1
if span <= equal_collapse_at:
for offset in range(span):
line_l = cdf_lines[i1 + offset]
line_r = yaml_lines[j1 + offset]
yield (Text(line_l, style="dim"), Text(line_r, style="dim"))
else:
head = equal_context
tail = equal_context
for offset in range(head):
yield (
Text(cdf_lines[i1 + offset], style="dim"),
Text(yaml_lines[j1 + offset], style="dim"),
)
omitted = span - head - tail
yield (
Text(f"... {omitted} unchanged line(s) ...", style="italic dim"),
Text(f"... {omitted} unchanged line(s) ...", style="italic dim"),
)
for offset in range(span - tail, span):
yield (
Text(cdf_lines[i1 + offset], style="dim"),
Text(yaml_lines[j1 + offset], style="dim"),
)
elif tag == "delete":
for idx in range(i1, i2):
yield (Text(cdf_lines[idx], style="red"), Text("-", style="dim"))
elif tag == "insert":
for idx in range(j1, j2):
yield (Text("-", style="dim"), Text(yaml_lines[idx], style="green"))
elif tag == "replace":
left = cdf_lines[i1:i2]
right = yaml_lines[j1:j2]
for k in range(max(len(left), len(right))):
l_txt = left[k] if k < len(left) else ""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
l_txt = left[k] if k < len(left) else ""
left_text = left[k] if k < len(left) else ""

r_txt = right[k] if k < len(right) else ""
Copy link
Copy Markdown
Contributor

@Magssch Magssch May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
r_txt = right[k] if k < len(right) else ""
right_text = right[k] if k < len(right) else ""

yield (Text(l_txt, style="yellow"), Text(r_txt, style="cyan"))
Copy link
Copy Markdown
Contributor

@Magssch Magssch May 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
yield (Text(l_txt, style="yellow"), Text(r_txt, style="cyan"))
yield (Text(left_text, style="yellow"), Text(right_text, style="cyan"))



def _build_side_by_side_table(cdf_lines: list[str], yaml_lines: list[str], *, cdf_project: str) -> ToolkitTable:
# Local build left, CDF (project) right — row cells are swapped from matcher order (CDF, build).
table = ToolkitTable("Local build", f"CDF ({cdf_project})", expand=True, padding=(0, 0))
table.columns[0].overflow = "fold"
table.columns[1].overflow = "fold"
table.columns[0].justify = "left"
table.columns[1].justify = "left"
for cdf_cell, build_cell in _side_by_side_rows(cdf_lines, yaml_lines):
table.add_row(build_cell, cdf_cell)
return table


def render_deploy_human_diff(
*,
resource_name: str,
identifier: Any,
source_file: Path,
cdf_dict: dict[str, Any],
yaml_dict: dict[str, Any],
sensitive_strings: Iterable[str],
cdf_project: str,
) -> ToolkitPanel:
Comment on lines +143 to +152
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The render_deploy_human_diff function is missing a docstring. According to the repository style guide, all functions must have concise docstrings using the Args: and Returns: format.

def render_deploy_human_diff(
    *,
    resource_name: str,
    identifier: Any,
    source_file: Path,
    cdf_dict: dict[str, Any],
    yaml_dict: dict[str, Any],
    sensitive_strings: Iterable[str],
    cdf_project: str,
) -> ToolkitPanel:
    """
    Renders a human-readable diff panel for a resource.

    Args:
        resource_name: The display name of the resource type.
        identifier: The unique identifier of the resource.
        source_file: The path to the local source file.
        cdf_dict: The resource data as retrieved from CDF.
        yaml_dict: The resource data as defined in the local build.
        sensitive_strings: Strings to be masked in the output.
        cdf_project: The name of the CDF project.

    Returns:
        ToolkitPanel: A panel containing the diff view.
    """
References
  1. All functions, methods, and class attributes must have type hints. Use concise docstrings with Args/Returns format. (link)

sens = list(sensitive_strings)
cdf_aligned, build_aligned = _align_nested_dict_pair_for_yaml(cdf_dict, yaml_dict)
cdf_yaml = _sanitize(yaml_safe_dump(cdf_aligned, sort_keys=False, indent=2), sens)
build_yaml = _sanitize(yaml_safe_dump(build_aligned, sort_keys=False, indent=2), sens)
cdf_lines = cdf_yaml.splitlines()
yaml_lines = build_yaml.splitlines()

matcher = SequenceMatcher(None, cdf_lines, yaml_lines)
delete_lines, insert_lines, replace_blocks, equal_lines = _summarize_opcodes(matcher.get_opcodes())

summary_lines = [
f"Serialized YAML: [dim]{len(cdf_lines)}[/] line(s) from CDF vs [dim]{len(yaml_lines)}[/] line(s) from build",
f"[green]✓[/] [bold]{equal_lines}[/] unchanged line(s)",
f"[yellow]↔[/] [bold]{replace_blocks}[/] replaced region(s)",
f"[red]-[/] [bold]{delete_lines}[/] line(s) only in CDF ([cyan]{cdf_project}[/])",
f"[green]+[/] [bold]{insert_lines}[/] line(s) only in local build",
]

sections = [
ToolkitPanelSection(
title="Resource",
content=[
f"Type: {resource_name}",
f"Identifier: {identifier!s}",
f"Source file: {source_file.as_posix()}",
],
content_padding=_SECTION_BODY_PADDING,
),
ToolkitPanelSection(
title="Summary",
content=summary_lines,
content_padding=_SECTION_BODY_PADDING,
),
ToolkitPanelSection(
content=[_build_side_by_side_table(cdf_lines, yaml_lines, cdf_project=cdf_project)],
),
]

return ToolkitPanel(
Group(*sections),
title=f"[bold]Diff view[/] - {resource_name}: {identifier!s}",
border_style=AuraColor.AMBER.rich,
)
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@

from .data_organization import DataSetsIO

HOSTED_EXTRACTOR_SOURCE_COMPARE_NOTE = (
"Hosted extractor sources are compared to CDF using only their external id (the full source "
"configuration is not read back from the API for diff). Deploy will therefore always include an update "
"for each source."
)
HOSTED_EXTRACTOR_DESTINATION_COMPARE_NOTE = (
"Hosted extractor destinations are compared to CDF using only their external id (the full destination "
"configuration is not read back from the API for diff). Deploy will therefore always include an update "
"for each destination."
)


@final
class HostedExtractorSourceIO(
Expand Down Expand Up @@ -116,9 +127,7 @@ def _iterate(
def dump_resource(
self, resource: HostedExtractorSourceResponseUnion, local: dict[str, Any] | None = None
) -> dict[str, Any]:
HighSeverityWarning(
"Sources will always be considered different, and thus will always be redeployed."
).print_warning(console=self.client.console)
HighSeverityWarning(HOSTED_EXTRACTOR_SOURCE_COMPARE_NOTE).print_warning(console=self.client.console)
return self.dump_id(self.get_id(resource))

def load_resource(self, resource: dict[str, Any], is_dry_run: bool = False) -> HostedExtractorSourceRequestUnion:
Expand Down Expand Up @@ -243,9 +252,7 @@ def load_resource(self, resource: dict[str, Any], is_dry_run: bool = False) -> H
def dump_resource(
self, resource: HostedExtractorDestinationResponse, local: dict[str, Any] | None = None
) -> dict[str, Any]:
HighSeverityWarning(
"Destinations will always be considered different, and thus will always be redeployed."
).print_warning(console=self.client.console)
HighSeverityWarning(HOSTED_EXTRACTOR_DESTINATION_COMPARE_NOTE).print_warning(console=self.client.console)
return self.dump_id(self.get_id(resource))

@classmethod
Expand Down
Loading
Loading