-
Notifications
You must be signed in to change notification settings - Fork 6
feat(deploy): human-readable deploy drift (--diff human) #2984
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f90eb56
9fea04f
bf21f79
e5b9a4d
2a42067
0f3db0c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The docstring for 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
|
||||||
| 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 "" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| r_txt = right[k] if k < len(right) else "" | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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")) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
|
|
||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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
|
||||||
| 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, | ||||||
| ) | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New feature, alpha flag?