From f27d1b4c7b4bd43f2ed6bf224f5b0316d7714b52 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Thu, 6 Aug 2026 14:31:37 +0300 Subject: [PATCH 1/8] Remove broken client.branch.diff_data() The method targeted GET /api/diff/data, a REST endpoint that does not exist in Infrahub, so every call returned a 404 (and the URL builder was also missing the ? separator). Instead of adding a server endpoint for it, drop the method and point users at the existing GraphQL-based client.get_diff_tree() / client.get_diff_summary(). Also removes InfraHubBranchManagerBase, whose only content was the diff_data URL builder, and updates the branches guide accordingly. Closes #325 Co-Authored-By: Claude Fable 5 --- changelog/325.removed.md | 1 + docs/docs/python-sdk/guides/branches.mdx | 33 ++++++++++-- infrahub_sdk/branch.py | 65 ++---------------------- 3 files changed, 33 insertions(+), 66 deletions(-) create mode 100644 changelog/325.removed.md diff --git a/changelog/325.removed.md b/changelog/325.removed.md new file mode 100644 index 000000000..e79b0c1d4 --- /dev/null +++ b/changelog/325.removed.md @@ -0,0 +1 @@ +Removed `client.branch.diff_data()` from both the async and sync clients. The method relied on a `GET /api/diff/data` REST endpoint that does not exist in Infrahub, so every call returned a 404. Use `client.get_diff_tree()` to retrieve the full diff of a branch against its base branch, or `client.get_diff_summary()` for the list of changed nodes; both use the `DiffTree` GraphQL query. diff --git a/docs/docs/python-sdk/guides/branches.mdx b/docs/docs/python-sdk/guides/branches.mdx index 64016048e..ffe2049d7 100644 --- a/docs/docs/python-sdk/guides/branches.mdx +++ b/docs/docs/python-sdk/guides/branches.mdx @@ -108,15 +108,40 @@ The Python SDK provides multiple methods to manage the branches in an Infrahub i -## Generating a diff for a branch +## Getting the diff for a branch + +Use `get_diff_tree` to retrieve the full diff of a branch compared to its base branch, including summary counts and the list of changed nodes. It returns `None` if no diff exists for the branch. + + + + + ```python + from infrahub_sdk import InfrahubClient + client = InfrahubClient() + diff = await client.get_diff_tree(branch="new-branch") + ``` + + + + + ```python + from infrahub_sdk import InfrahubClientSync + client = InfrahubClientSync() + diff = client.get_diff_tree(branch="new-branch") + ``` + + + + +If you only need the list of changed nodes, `get_diff_summary` returns them without the diff metadata. ```python from infrahub_sdk import InfrahubClient - client = await InfrahubClient() - diff = await client.branch.diff_data(branch_name="new-branch") + client = InfrahubClient() + node_diffs = await client.get_diff_summary(branch="new-branch") ``` @@ -125,7 +150,7 @@ The Python SDK provides multiple methods to manage the branches in an Infrahub i ```python from infrahub_sdk import InfrahubClientSync client = InfrahubClientSync() - diff = client.branch.diff_data(branch_name="new-branch") + node_diffs = client.get_diff_summary(branch="new-branch") ``` diff --git a/infrahub_sdk/branch.py b/infrahub_sdk/branch.py index 5e1459a6d..b310522e4 100644 --- a/infrahub_sdk/branch.py +++ b/infrahub_sdk/branch.py @@ -1,14 +1,12 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING, Any, Literal, overload -from urllib.parse import urlencode +from typing import TYPE_CHECKING, Literal, overload from pydantic import BaseModel from .exceptions import BranchNotFoundError from .graphql import Mutation, Query -from .utils import decode_json if TYPE_CHECKING: from .client import InfrahubClient, InfrahubClientSync @@ -61,30 +59,7 @@ class BranchData(BaseModel): QUERY_ONE_BRANCH_DATA = {"Branch": {**BRANCH_DATA, **BRANCH_DATA_FILTER}} -class InfraHubBranchManagerBase: - @classmethod - def generate_diff_data_url( - cls, - client: InfrahubClient | InfrahubClientSync, - branch_name: str, - branch_only: bool = True, - time_from: str | None = None, - time_to: str | None = None, - ) -> str: - """Generate the URL for the diff_data function.""" - url = f"{client.address}/api/diff/data" - url_params = {} - url_params["branch"] = branch_name - url_params["branch_only"] = str(branch_only).lower() - if time_from: - url_params["time_from"] = time_from - if time_to: - url_params["time_to"] = time_to - - return url + urlencode(url_params) - - -class InfrahubBranchManager(InfraHubBranchManagerBase): +class InfrahubBranchManager: def __init__(self, client: InfrahubClient) -> None: self.client = client @@ -206,25 +181,8 @@ async def get(self, branch_name: str) -> BranchData: raise BranchNotFoundError(identifier=branch_name) return BranchData(**data["Branch"][0]) - async def diff_data( - self, - branch_name: str, - branch_only: bool = True, - time_from: str | None = None, - time_to: str | None = None, - ) -> dict[Any, Any]: - url = self.generate_diff_data_url( - client=self.client, - branch_name=branch_name, - branch_only=branch_only, - time_from=time_from, - time_to=time_to, - ) - response = await self.client._get(url=url, headers=self.client.headers) - return decode_json(response=response) - -class InfrahubBranchManagerSync(InfraHubBranchManagerBase): +class InfrahubBranchManagerSync: def __init__(self, client: InfrahubClientSync) -> None: self.client = client @@ -300,23 +258,6 @@ def delete(self, branch_name: str) -> bool: response = self.client.execute_graphql(query=query.render(), tracker="mutation-branch-delete") return response["BranchDelete"]["ok"] - def diff_data( - self, - branch_name: str, - branch_only: bool = True, - time_from: str | None = None, - time_to: str | None = None, - ) -> dict[Any, Any]: - url = self.generate_diff_data_url( - client=self.client, - branch_name=branch_name, - branch_only=branch_only, - time_from=time_from, - time_to=time_to, - ) - response = self.client._get(url=url, headers=self.client.headers) - return decode_json(response=response) - def merge(self, branch_name: str) -> bool: input_data = { "data": { From 923706999f7deded4e2ff3d10c08078387280bf7 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Thu, 6 Aug 2026 14:50:58 +0300 Subject: [PATCH 2/8] Add include_properties to get_diff_tree for value-level diff details The DiffTree GraphQL query exposes previous/new values per property but the SDK only fetched summary counts, so removing diff_data() would have left no way to retrieve the data-level diff it was meant to provide. With include_properties=True the diff tree now includes value-level details per attribute property and peer id/label per relationship element. Co-Authored-By: Claude Fable 5 --- changelog/+get-diff-tree-properties.added.md | 1 + changelog/325.removed.md | 2 +- docs/docs/python-sdk/guides/branches.mdx | 6 +- infrahub_sdk/client.py | 12 +- infrahub_sdk/diff.py | 64 +++++- tests/integration/test_diff_tree.py | 85 ++++++++ tests/unit/sdk/test_diff_summary.py | 212 +++++++++++++++++++ 7 files changed, 370 insertions(+), 12 deletions(-) create mode 100644 changelog/+get-diff-tree-properties.added.md create mode 100644 tests/integration/test_diff_tree.py diff --git a/changelog/+get-diff-tree-properties.added.md b/changelog/+get-diff-tree-properties.added.md new file mode 100644 index 000000000..e359ffcc2 --- /dev/null +++ b/changelog/+get-diff-tree-properties.added.md @@ -0,0 +1 @@ +Added an `include_properties` argument to `client.get_diff_tree()` (async and sync). When set to `True`, the returned diff tree includes the value-level details of each change: previous/new value per attribute property, and peer id/label per relationship element. This restores the data-level diff that `client.branch.diff_data()` was meant to provide, through the `DiffTree` GraphQL query. diff --git a/changelog/325.removed.md b/changelog/325.removed.md index e79b0c1d4..4a4e5a19b 100644 --- a/changelog/325.removed.md +++ b/changelog/325.removed.md @@ -1 +1 @@ -Removed `client.branch.diff_data()` from both the async and sync clients. The method relied on a `GET /api/diff/data` REST endpoint that does not exist in Infrahub, so every call returned a 404. Use `client.get_diff_tree()` to retrieve the full diff of a branch against its base branch, or `client.get_diff_summary()` for the list of changed nodes; both use the `DiffTree` GraphQL query. +Removed `client.branch.diff_data()` from both the async and sync clients. The method relied on a `GET /api/diff/data` REST endpoint that does not exist in Infrahub, so every call returned a 404. Use `client.get_diff_tree()` (with `include_properties=True` for value-level details) to retrieve the full diff of a branch against its base branch, or `client.get_diff_summary()` for the list of changed nodes; both use the `DiffTree` GraphQL query. diff --git a/docs/docs/python-sdk/guides/branches.mdx b/docs/docs/python-sdk/guides/branches.mdx index ffe2049d7..1cd60a4ae 100644 --- a/docs/docs/python-sdk/guides/branches.mdx +++ b/docs/docs/python-sdk/guides/branches.mdx @@ -110,7 +110,7 @@ The Python SDK provides multiple methods to manage the branches in an Infrahub i ## Getting the diff for a branch -Use `get_diff_tree` to retrieve the full diff of a branch compared to its base branch, including summary counts and the list of changed nodes. It returns `None` if no diff exists for the branch. +Use `get_diff_tree` to retrieve the full diff of a branch compared to its base branch, including summary counts and the list of changed nodes. It returns `None` if no diff exists for the branch. Set `include_properties=True` to also retrieve the value-level details of each change (previous and new value per property). @@ -118,7 +118,7 @@ Use `get_diff_tree` to retrieve the full diff of a branch compared to its base b ```python from infrahub_sdk import InfrahubClient client = InfrahubClient() - diff = await client.get_diff_tree(branch="new-branch") + diff = await client.get_diff_tree(branch="new-branch", include_properties=True) ``` @@ -127,7 +127,7 @@ Use `get_diff_tree` to retrieve the full diff of a branch compared to its base b ```python from infrahub_sdk import InfrahubClientSync client = InfrahubClientSync() - diff = client.get_diff_tree(branch="new-branch") + diff = client.get_diff_tree(branch="new-branch", include_properties=True) ``` diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index baca5ec88..84ec803e9 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -1841,16 +1841,20 @@ async def get_diff_tree( timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None, + include_properties: bool = False, ) -> DiffTreeData | None: """Get complete diff tree with metadata and nodes. + Set ``include_properties`` to True to also retrieve the value-level details + of each change (previous/new values per property). + Returns None if no diff exists. Raises: ValueError: If ``from_time`` is later than ``to_time``. """ - query = get_diff_tree_query() + query = get_diff_tree_query(include_properties=include_properties) input_data = {"branch_name": branch} if name: input_data["name"] = name @@ -3433,16 +3437,20 @@ def get_diff_tree( timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None, + include_properties: bool = False, ) -> DiffTreeData | None: """Get complete diff tree with metadata and nodes. + Set ``include_properties`` to True to also retrieve the value-level details + of each change (previous/new values per property). + Returns None if no diff exists. Raises: ValueError: If ``from_time`` is later than ``to_time``. """ - query = get_diff_tree_query() + query = get_diff_tree_query(include_properties=include_properties) input_data = {"branch_name": branch} if name: input_data["name"] = name diff --git a/infrahub_sdk/diff.py b/infrahub_sdk/diff.py index fd4d9308d..56b061582 100644 --- a/infrahub_sdk/diff.py +++ b/infrahub_sdk/diff.py @@ -23,6 +23,7 @@ class NodeDiffElement(TypedDict): action: str summary: NodeDiffSummary peers: NotRequired[list[NodeDiffPeer]] + properties: NotRequired[list[NodeDiffProperty]] class NodeDiffSummary(TypedDict): @@ -34,6 +35,18 @@ class NodeDiffSummary(TypedDict): class NodeDiffPeer(TypedDict): action: str summary: NodeDiffSummary + peer_id: NotRequired[str] + peer_label: NotRequired[str | None] + properties: NotRequired[list[NodeDiffProperty]] + + +class NodeDiffProperty(TypedDict): + property_type: str + action: str + previous_value: str | None + new_value: str | None + previous_label: str | None + new_label: str | None class DiffTreeData(TypedDict): @@ -88,6 +101,20 @@ def get_diff_summary_query() -> str: """ +def _diff_properties_to_node_diff_properties(property_dicts: list[dict[str, Any]]) -> list[NodeDiffProperty]: + return [ + NodeDiffProperty( + property_type=str(property_dict.get("property_type")), + action=str(property_dict.get("status")), + previous_value=property_dict.get("previous_value"), + new_value=property_dict.get("new_value"), + previous_label=property_dict.get("previous_label"), + new_label=property_dict.get("new_label"), + ) + for property_dict in property_dicts + ] + + def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> NodeDiff: element_diffs: list[NodeDiffElement] = [] if "attributes" in node_dict: @@ -102,6 +129,8 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> "updated": int(attr_dict.get("num_updated") or 0), }, ) + if attr_dict.get("properties"): + attr_diff["properties"] = _diff_properties_to_node_diff_properties(attr_dict["properties"]) element_diffs.append(attr_diff) if "relationships" in node_dict: for relationship_dict in node_dict["relationships"]: @@ -117,8 +146,9 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> }, ) if not is_cardinality_one and "elements" in relationship_dict: - peer_diffs = [ - NodeDiffPeer( + peer_diffs = [] + for element_dict in relationship_dict["elements"]: + peer_diff = NodeDiffPeer( action=str(element_dict.get("status")), summary={ "added": int(element_dict.get("num_added") or 0), @@ -126,9 +156,17 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> "updated": int(element_dict.get("num_updated") or 0), }, ) - for element_dict in relationship_dict["elements"] - ] + if "peer_id" in element_dict: + peer_diff["peer_id"] = str(element_dict["peer_id"]) + peer_diff["peer_label"] = element_dict.get("peer_label") + if element_dict.get("properties"): + peer_diff["properties"] = _diff_properties_to_node_diff_properties(element_dict["properties"]) + peer_diffs.append(peer_diff) relationship_diff["peers"] = peer_diffs + elif is_cardinality_one and relationship_dict.get("elements"): + element_properties = relationship_dict["elements"][0].get("properties") + if element_properties: + relationship_diff["properties"] = _diff_properties_to_node_diff_properties(element_properties) element_diffs.append(relationship_diff) return NodeDiff( branch=branch_name, @@ -140,8 +178,8 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> ) -def get_diff_tree_query() -> Query: - node_structure = { +def get_diff_tree_query(include_properties: bool = False) -> Query: + node_structure: dict[str, Any] = { "uuid": None, "kind": None, "status": None, @@ -172,6 +210,20 @@ def get_diff_tree_query() -> Query: }, } + if include_properties: + property_structure = { + "property_type": None, + "status": None, + "previous_value": None, + "new_value": None, + "previous_label": None, + "new_label": None, + } + node_structure["attributes"]["properties"] = property_structure + node_structure["relationships"]["elements"]["peer_id"] = None + node_structure["relationships"]["elements"]["peer_label"] = None + node_structure["relationships"]["elements"]["properties"] = property_structure + return Query( name="GetDiffTree", query={ diff --git a/tests/integration/test_diff_tree.py b/tests/integration/test_diff_tree.py new file mode 100644 index 000000000..007cab017 --- /dev/null +++ b/tests/integration/test_diff_tree.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +import pytest + +from infrahub_sdk.testing.docker import TestInfrahubDockerClient +from infrahub_sdk.testing.schemas.animal import TESTING_DOG, SchemaAnimal + +if TYPE_CHECKING: + from infrahub_sdk import InfrahubClient + from infrahub_sdk.node import InfrahubNode + +DIFF_BRANCH = "diff-branch" +DIFF_NAME = "diff-branch-test-diff" +NEW_COLOR = "#000000" + + +class TestGetDiffTree(TestInfrahubDockerClient, SchemaAnimal): + @pytest.fixture(scope="class") + async def branch_with_changes( + self, + client: InfrahubClient, + load_schema: None, + person_liam: InfrahubNode, + person_sophia: InfrahubNode, + dog_rocky: InfrahubNode, + ) -> None: + """Create a branch, change an attribute and a relationship on it and compute the diff.""" + branch = await client.branch.create(branch_name=DIFF_BRANCH) + + dog = await client.get(kind=TESTING_DOG, id=dog_rocky.id, branch=DIFF_BRANCH) + dog.color.value = NEW_COLOR + dog.owner = person_liam.id + await dog.save() + + await client.create_diff( + branch=DIFF_BRANCH, + name=DIFF_NAME, + from_time=datetime.fromisoformat(branch.branched_from.replace("Z", "+00:00")), + to_time=datetime.now(timezone.utc), + ) + + async def test_get_diff_tree_summary_only( + self, client: InfrahubClient, branch_with_changes: None, dog_rocky: InfrahubNode + ) -> None: + """Without include_properties the diff tree only contains summary counts.""" + diff_tree = await client.get_diff_tree(branch=DIFF_BRANCH) + + assert diff_tree is not None + assert diff_tree["base_branch"] == "main" + assert diff_tree["diff_branch"] == DIFF_BRANCH + dog_node = next(node for node in diff_tree["nodes"] if node["id"] == dog_rocky.id) + assert dog_node["action"] == "UPDATED" + color_element = next(element for element in dog_node["elements"] if element["name"] == "color") + assert "properties" not in color_element + + async def test_get_diff_tree_with_properties( + self, + client: InfrahubClient, + branch_with_changes: None, + person_liam: InfrahubNode, + person_sophia: InfrahubNode, + dog_rocky: InfrahubNode, + ) -> None: + """With include_properties the diff tree contains previous/new values per change.""" + diff_tree = await client.get_diff_tree(branch=DIFF_BRANCH, include_properties=True) + + assert diff_tree is not None + dog_node = next(node for node in diff_tree["nodes"] if node["id"] == dog_rocky.id) + + color_element = next(element for element in dog_node["elements"] if element["name"] == "color") + assert color_element["element_type"] == "ATTRIBUTE" + color_value = next(prop for prop in color_element["properties"] if prop["property_type"] == "HAS_VALUE") + assert color_value["action"] == "UPDATED" + assert color_value["previous_value"] == "#784212" + assert color_value["new_value"] == NEW_COLOR + + owner_element = next(element for element in dog_node["elements"] if element["name"] == "owner") + assert owner_element["element_type"] == "RELATIONSHIP_ONE" + owner_value = next(prop for prop in owner_element["properties"] if prop["property_type"] == "IS_RELATED") + assert owner_value["action"] == "UPDATED" + assert owner_value["previous_value"] == person_sophia.id + assert owner_value["new_value"] == person_liam.id diff --git a/tests/unit/sdk/test_diff_summary.py b/tests/unit/sdk/test_diff_summary.py index fddd388b0..59b9e99c7 100644 --- a/tests/unit/sdk/test_diff_summary.py +++ b/tests/unit/sdk/test_diff_summary.py @@ -393,3 +393,215 @@ async def test_get_diff_tree_time_validation(clients: BothClients, client_type: from_time=from_time, to_time=to_time, ) + + +def test_get_diff_tree_query_with_properties() -> None: + """Test that include_properties adds the value-level fields to the query.""" + rendered_without = get_diff_tree_query().render() + assert "properties" not in rendered_without + assert "previous_value" not in rendered_without + + rendered = get_diff_tree_query(include_properties=True).render() + assert "properties" in rendered + assert "property_type" in rendered + assert "previous_value" in rendered + assert "new_value" in rendered + assert "previous_label" in rendered + assert "new_label" in rendered + assert "peer_id" in rendered + assert "peer_label" in rendered + + +@pytest.fixture +async def mock_diff_tree_with_properties(httpx_mock: HTTPXMock, client: InfrahubClient) -> HTTPXMock: + """Mock diff tree response including value-level property details.""" + response = { + "data": { + "DiffTree": { + "num_added": 0, + "num_updated": 2, + "num_removed": 0, + "num_conflicts": 0, + "to_time": "2025-11-14T12:00:00Z", + "from_time": "2025-11-01T00:00:00Z", + "base_branch": "main", + "diff_branch": "feature-branch", + "name": None, + "nodes": [ + { + "attributes": [ + { + "name": "name", + "num_added": 0, + "num_removed": 0, + "num_updated": 1, + "status": "UPDATED", + "properties": [ + { + "property_type": "HAS_VALUE", + "status": "UPDATED", + "previous_value": "John", + "new_value": "Jonathan", + "previous_label": None, + "new_label": None, + } + ], + } + ], + "kind": "TestPerson", + "label": "Jonathan", + "num_added": 0, + "num_removed": 0, + "num_updated": 1, + "relationships": [ + { + "cardinality": "MANY", + "name": "cars", + "num_added": 1, + "num_removed": 0, + "num_updated": 0, + "status": "UPDATED", + "elements": [ + { + "status": "ADDED", + "num_added": 3, + "num_removed": 0, + "num_updated": 0, + "peer_id": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", + "peer_label": "nolt #444444", + "properties": [ + { + "property_type": "IS_RELATED", + "status": "ADDED", + "previous_value": None, + "new_value": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", + "previous_label": None, + "new_label": "nolt #444444", + } + ], + } + ], + } + ], + "status": "UPDATED", + "uuid": "17fbadf0-6243-5d3c-43ee-167718ff8dac", + }, + { + "attributes": [], + "kind": "TestCar", + "label": "nolt #444444", + "num_added": 0, + "num_removed": 0, + "num_updated": 1, + "relationships": [ + { + "cardinality": "ONE", + "name": "owner", + "num_added": 0, + "num_removed": 0, + "num_updated": 1, + "status": "UPDATED", + "elements": [ + { + "status": "UPDATED", + "num_added": 0, + "num_removed": 0, + "num_updated": 1, + "peer_id": "17fbadf0-634f-05a8-43e4-1677e744d4c0", + "peer_label": "Jane", + "properties": [ + { + "property_type": "IS_RELATED", + "status": "UPDATED", + "previous_value": "17fbadf0-634f-05a8-43e4-1677e744d4c0", + "new_value": "17fbadf0-6243-5d3c-43ee-167718ff8dac", + "previous_label": "Jane", + "new_label": "Jonathan", + } + ], + } + ], + } + ], + "status": "UPDATED", + "uuid": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", + }, + ], + } + } + } + + httpx_mock.add_response( + method="POST", + json=response, + match_headers={"X-Infrahub-Tracker": "query-difftree-properties"}, + ) + return httpx_mock + + +@pytest.mark.parametrize("client_type", client_types) +async def test_get_diff_tree_with_properties( + clients: BothClients, mock_diff_tree_with_properties: HTTPXMock, client_type: str +) -> None: + """Test get_diff_tree with include_properties returns value-level details.""" + if client_type == "standard": + diff_tree = await clients.standard.get_diff_tree( + branch="feature-branch", + tracker="query-difftree-properties", + include_properties=True, + ) + else: + diff_tree = clients.sync.get_diff_tree( + branch="feature-branch", + tracker="query-difftree-properties", + include_properties=True, + ) + + assert diff_tree is not None + person = next(node for node in diff_tree["nodes"] if node["kind"] == "TestPerson") + name_element = next(element for element in person["elements"] if element["name"] == "name") + assert name_element["properties"] == [ + { + "property_type": "HAS_VALUE", + "action": "UPDATED", + "previous_value": "John", + "new_value": "Jonathan", + "previous_label": None, + "new_label": None, + } + ] + + cars_element = next(element for element in person["elements"] if element["name"] == "cars") + assert cars_element["element_type"] == "RELATIONSHIP_MANY" + assert cars_element["peers"] == [ + { + "action": "ADDED", + "summary": {"added": 3, "removed": 0, "updated": 0}, + "peer_id": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", + "peer_label": "nolt #444444", + "properties": [ + { + "property_type": "IS_RELATED", + "action": "ADDED", + "previous_value": None, + "new_value": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", + "previous_label": None, + "new_label": "nolt #444444", + } + ], + } + ] + + car = next(node for node in diff_tree["nodes"] if node["kind"] == "TestCar") + owner_element = next(element for element in car["elements"] if element["name"] == "owner") + assert owner_element["element_type"] == "RELATIONSHIP_ONE" + assert owner_element["properties"] == [ + { + "property_type": "IS_RELATED", + "action": "UPDATED", + "previous_value": "17fbadf0-634f-05a8-43e4-1677e744d4c0", + "new_value": "17fbadf0-6243-5d3c-43ee-167718ff8dac", + "previous_label": "Jane", + "new_label": "Jonathan", + } + ] From 7007816e2aa8fd5f2dc99f5d8de669a49f38cf96 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Thu, 6 Aug 2026 15:17:12 +0300 Subject: [PATCH 3/8] ci: regenerate SDK reference docs for get_diff_tree signature change Co-Authored-By: Claude Fable 5 --- docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx index 1f783d46c..7d6346b23 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx @@ -403,11 +403,14 @@ get_diff_summary(self, branch: str, name: str | None = None, from_time: datetime #### `get_diff_tree` ```python -get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None) -> DiffTreeData | None +get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None, include_properties: bool = False) -> DiffTreeData | None ``` Get complete diff tree with metadata and nodes. +Set ``include_properties`` to True to also retrieve the value-level details +of each change (previous/new values per property). + Returns None if no diff exists. **Raises:** @@ -932,11 +935,14 @@ get_diff_summary(self, branch: str, name: str | None = None, from_time: datetime #### `get_diff_tree` ```python -get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None) -> DiffTreeData | None +get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None, include_properties: bool = False) -> DiffTreeData | None ``` Get complete diff tree with metadata and nodes. +Set ``include_properties`` to True to also retrieve the value-level details +of each change (previous/new values per property). + Returns None if no diff exists. **Raises:** From 9a660afae46efece312ea8bfa2baa5769c3e2eac Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Thu, 6 Aug 2026 17:27:26 +0300 Subject: [PATCH 4/8] Expose peer_id/peer_label on cardinality-one relationship diff elements The query already fetched them but the parser dropped them for ONE relationships, leaving the IS_RELATED property as the only way to identify the changed peer. Co-Authored-By: Claude Fable 5 --- infrahub_sdk/diff.py | 13 ++++++++++--- tests/integration/test_diff_tree.py | 2 ++ tests/unit/sdk/test_diff_summary.py | 2 ++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/infrahub_sdk/diff.py b/infrahub_sdk/diff.py index 56b061582..b2f9aed71 100644 --- a/infrahub_sdk/diff.py +++ b/infrahub_sdk/diff.py @@ -23,6 +23,8 @@ class NodeDiffElement(TypedDict): action: str summary: NodeDiffSummary peers: NotRequired[list[NodeDiffPeer]] + peer_id: NotRequired[str] + peer_label: NotRequired[str | None] properties: NotRequired[list[NodeDiffProperty]] @@ -164,9 +166,14 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> peer_diffs.append(peer_diff) relationship_diff["peers"] = peer_diffs elif is_cardinality_one and relationship_dict.get("elements"): - element_properties = relationship_dict["elements"][0].get("properties") - if element_properties: - relationship_diff["properties"] = _diff_properties_to_node_diff_properties(element_properties) + element_dict = relationship_dict["elements"][0] + if "peer_id" in element_dict: + relationship_diff["peer_id"] = str(element_dict["peer_id"]) + relationship_diff["peer_label"] = element_dict.get("peer_label") + if element_dict.get("properties"): + relationship_diff["properties"] = _diff_properties_to_node_diff_properties( + element_dict["properties"] + ) element_diffs.append(relationship_diff) return NodeDiff( branch=branch_name, diff --git a/tests/integration/test_diff_tree.py b/tests/integration/test_diff_tree.py index 007cab017..07b723222 100644 --- a/tests/integration/test_diff_tree.py +++ b/tests/integration/test_diff_tree.py @@ -79,6 +79,8 @@ async def test_get_diff_tree_with_properties( owner_element = next(element for element in dog_node["elements"] if element["name"] == "owner") assert owner_element["element_type"] == "RELATIONSHIP_ONE" + assert owner_element["peer_id"] in {person_liam.id, person_sophia.id} + assert "peer_label" in owner_element owner_value = next(prop for prop in owner_element["properties"] if prop["property_type"] == "IS_RELATED") assert owner_value["action"] == "UPDATED" assert owner_value["previous_value"] == person_sophia.id diff --git a/tests/unit/sdk/test_diff_summary.py b/tests/unit/sdk/test_diff_summary.py index 59b9e99c7..754d98a13 100644 --- a/tests/unit/sdk/test_diff_summary.py +++ b/tests/unit/sdk/test_diff_summary.py @@ -595,6 +595,8 @@ async def test_get_diff_tree_with_properties( car = next(node for node in diff_tree["nodes"] if node["kind"] == "TestCar") owner_element = next(element for element in car["elements"] if element["name"] == "owner") assert owner_element["element_type"] == "RELATIONSHIP_ONE" + assert owner_element["peer_id"] == "17fbadf0-634f-05a8-43e4-1677e744d4c0" + assert owner_element["peer_label"] == "Jane" assert owner_element["properties"] == [ { "property_type": "IS_RELATED", From 24ded4e2299048c60ed27f8e784158e71ee2b2d4 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 7 Aug 2026 10:55:16 +0300 Subject: [PATCH 5/8] Simplify relationship diff parsing and merge changelog fragments Extract the element-to-peer conversion into a helper shared by both cardinality branches, and stop silently dropping trailing elements when a cardinality-one relationship unexpectedly carries several: they now come back as peers, same shape as cardinality-many. The include_properties addition is folded into the removal changelog entry since it exists as the diff_data() replacement. Co-Authored-By: Claude Fable 5 --- changelog/+get-diff-tree-properties.added.md | 1 - changelog/325.removed.md | 2 +- infrahub_sdk/diff.py | 48 +++++++++++------- tests/unit/sdk/test_diff_summary.py | 53 +++++++++++++++++++- 4 files changed, 82 insertions(+), 22 deletions(-) delete mode 100644 changelog/+get-diff-tree-properties.added.md diff --git a/changelog/+get-diff-tree-properties.added.md b/changelog/+get-diff-tree-properties.added.md deleted file mode 100644 index e359ffcc2..000000000 --- a/changelog/+get-diff-tree-properties.added.md +++ /dev/null @@ -1 +0,0 @@ -Added an `include_properties` argument to `client.get_diff_tree()` (async and sync). When set to `True`, the returned diff tree includes the value-level details of each change: previous/new value per attribute property, and peer id/label per relationship element. This restores the data-level diff that `client.branch.diff_data()` was meant to provide, through the `DiffTree` GraphQL query. diff --git a/changelog/325.removed.md b/changelog/325.removed.md index 4a4e5a19b..3f5dd881d 100644 --- a/changelog/325.removed.md +++ b/changelog/325.removed.md @@ -1 +1 @@ -Removed `client.branch.diff_data()` from both the async and sync clients. The method relied on a `GET /api/diff/data` REST endpoint that does not exist in Infrahub, so every call returned a 404. Use `client.get_diff_tree()` (with `include_properties=True` for value-level details) to retrieve the full diff of a branch against its base branch, or `client.get_diff_summary()` for the list of changed nodes; both use the `DiffTree` GraphQL query. +Removed `client.branch.diff_data()` from both the async and sync clients. The method relied on a `GET /api/diff/data` REST endpoint that does not exist in Infrahub, so every call returned a 404. Use `client.get_diff_tree()` instead, which now accepts `include_properties=True` to also return the value-level details of each change (previous/new value per attribute property, peer id/label per relationship element), or `client.get_diff_summary()` for the list of changed nodes; both use the `DiffTree` GraphQL query. diff --git a/infrahub_sdk/diff.py b/infrahub_sdk/diff.py index b2f9aed71..425ff01ee 100644 --- a/infrahub_sdk/diff.py +++ b/infrahub_sdk/diff.py @@ -117,6 +117,23 @@ def _diff_properties_to_node_diff_properties(property_dicts: list[dict[str, Any] ] +def _diff_element_to_node_diff_peer(element_dict: dict[str, Any]) -> NodeDiffPeer: + peer_diff = NodeDiffPeer( + action=str(element_dict.get("status")), + summary={ + "added": int(element_dict.get("num_added") or 0), + "removed": int(element_dict.get("num_removed") or 0), + "updated": int(element_dict.get("num_updated") or 0), + }, + ) + if "peer_id" in element_dict: + peer_diff["peer_id"] = str(element_dict["peer_id"]) + peer_diff["peer_label"] = element_dict.get("peer_label") + if element_dict.get("properties"): + peer_diff["properties"] = _diff_properties_to_node_diff_properties(element_dict["properties"]) + return peer_diff + + def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> NodeDiff: element_diffs: list[NodeDiffElement] = [] if "attributes" in node_dict: @@ -147,26 +164,13 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> "updated": int(relationship_dict.get("num_updated") or 0), }, ) + element_dicts = relationship_dict.get("elements") or [] if not is_cardinality_one and "elements" in relationship_dict: - peer_diffs = [] - for element_dict in relationship_dict["elements"]: - peer_diff = NodeDiffPeer( - action=str(element_dict.get("status")), - summary={ - "added": int(element_dict.get("num_added") or 0), - "removed": int(element_dict.get("num_removed") or 0), - "updated": int(element_dict.get("num_updated") or 0), - }, - ) - if "peer_id" in element_dict: - peer_diff["peer_id"] = str(element_dict["peer_id"]) - peer_diff["peer_label"] = element_dict.get("peer_label") - if element_dict.get("properties"): - peer_diff["properties"] = _diff_properties_to_node_diff_properties(element_dict["properties"]) - peer_diffs.append(peer_diff) - relationship_diff["peers"] = peer_diffs - elif is_cardinality_one and relationship_dict.get("elements"): - element_dict = relationship_dict["elements"][0] + relationship_diff["peers"] = [ + _diff_element_to_node_diff_peer(element_dict) for element_dict in element_dicts + ] + elif is_cardinality_one and len(element_dicts) == 1: + element_dict = element_dicts[0] if "peer_id" in element_dict: relationship_diff["peer_id"] = str(element_dict["peer_id"]) relationship_diff["peer_label"] = element_dict.get("peer_label") @@ -174,6 +178,12 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> relationship_diff["properties"] = _diff_properties_to_node_diff_properties( element_dict["properties"] ) + elif is_cardinality_one and element_dicts: + # a cardinality-one diff normally has a single element; if the server + # ever returns several, keep them all instead of flattening one + relationship_diff["peers"] = [ + _diff_element_to_node_diff_peer(element_dict) for element_dict in element_dicts + ] element_diffs.append(relationship_diff) return NodeDiff( branch=branch_name, diff --git a/tests/unit/sdk/test_diff_summary.py b/tests/unit/sdk/test_diff_summary.py index 754d98a13..f86f7eddb 100644 --- a/tests/unit/sdk/test_diff_summary.py +++ b/tests/unit/sdk/test_diff_summary.py @@ -4,7 +4,7 @@ from pytest_httpx import HTTPXMock from infrahub_sdk import InfrahubClient -from infrahub_sdk.diff import get_diff_tree_query +from infrahub_sdk.diff import diff_tree_node_to_node_diff, get_diff_tree_query from tests.unit.sdk.conftest import BothClients client_types = ["standard", "sync"] @@ -607,3 +607,54 @@ async def test_get_diff_tree_with_properties( "new_label": "Jonathan", } ] + + +def test_cardinality_one_with_multiple_elements_keeps_all_peers() -> None: + """A cardinality-one relationship with several elements must not flatten to a single peer.""" + node_dict = { + "uuid": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", + "kind": "TestCar", + "status": "UPDATED", + "label": "nolt #444444", + "attributes": [], + "relationships": [ + { + "cardinality": "ONE", + "name": "owner", + "num_added": 1, + "num_removed": 1, + "num_updated": 0, + "status": "UPDATED", + "elements": [ + { + "status": "REMOVED", + "num_added": 0, + "num_removed": 1, + "num_updated": 0, + "peer_id": "17fbadf0-634f-05a8-43e4-1677e744d4c0", + "peer_label": "Jane", + "properties": [], + }, + { + "status": "ADDED", + "num_added": 1, + "num_removed": 0, + "num_updated": 0, + "peer_id": "17fbadf0-6243-5d3c-43ee-167718ff8dac", + "peer_label": "Jonathan", + "properties": [], + }, + ], + } + ], + } + + node_diff = diff_tree_node_to_node_diff(node_dict=node_dict, branch_name="branch2") + + owner_element = node_diff["elements"][0] + assert owner_element["element_type"] == "RELATIONSHIP_ONE" + assert "peer_id" not in owner_element + assert [(peer["peer_id"], peer["action"]) for peer in owner_element["peers"]] == [ + ("17fbadf0-634f-05a8-43e4-1677e744d4c0", "REMOVED"), + ("17fbadf0-6243-5d3c-43ee-167718ff8dac", "ADDED"), + ] From 65505070eebdccda9cfd63c7f5f3e9fb39cbbe9e Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 7 Aug 2026 11:51:30 +0300 Subject: [PATCH 6/8] test: unit test _diff_element_to_node_diff_peer and reuse it for cardinality-one flattening Co-Authored-By: Claude Fable 5 --- infrahub_sdk/diff.py | 14 +++--- tests/unit/sdk/test_diff_summary.py | 75 ++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/infrahub_sdk/diff.py b/infrahub_sdk/diff.py index 425ff01ee..d932fb0f4 100644 --- a/infrahub_sdk/diff.py +++ b/infrahub_sdk/diff.py @@ -170,14 +170,12 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> _diff_element_to_node_diff_peer(element_dict) for element_dict in element_dicts ] elif is_cardinality_one and len(element_dicts) == 1: - element_dict = element_dicts[0] - if "peer_id" in element_dict: - relationship_diff["peer_id"] = str(element_dict["peer_id"]) - relationship_diff["peer_label"] = element_dict.get("peer_label") - if element_dict.get("properties"): - relationship_diff["properties"] = _diff_properties_to_node_diff_properties( - element_dict["properties"] - ) + peer_diff = _diff_element_to_node_diff_peer(element_dicts[0]) + if "peer_id" in peer_diff: + relationship_diff["peer_id"] = peer_diff["peer_id"] + relationship_diff["peer_label"] = peer_diff.get("peer_label") + if "properties" in peer_diff: + relationship_diff["properties"] = peer_diff["properties"] elif is_cardinality_one and element_dicts: # a cardinality-one diff normally has a single element; if the server # ever returns several, keep them all instead of flattening one diff --git a/tests/unit/sdk/test_diff_summary.py b/tests/unit/sdk/test_diff_summary.py index f86f7eddb..31283586d 100644 --- a/tests/unit/sdk/test_diff_summary.py +++ b/tests/unit/sdk/test_diff_summary.py @@ -4,7 +4,11 @@ from pytest_httpx import HTTPXMock from infrahub_sdk import InfrahubClient -from infrahub_sdk.diff import diff_tree_node_to_node_diff, get_diff_tree_query +from infrahub_sdk.diff import ( + _diff_element_to_node_diff_peer, # noqa: PLC2701 + diff_tree_node_to_node_diff, + get_diff_tree_query, +) from tests.unit.sdk.conftest import BothClients client_types = ["standard", "sync"] @@ -658,3 +662,72 @@ def test_cardinality_one_with_multiple_elements_keeps_all_peers() -> None: ("17fbadf0-634f-05a8-43e4-1677e744d4c0", "REMOVED"), ("17fbadf0-6243-5d3c-43ee-167718ff8dac", "ADDED"), ] + + +def test_diff_element_to_node_diff_peer_counts_only() -> None: + """Elements from the summary query carry only a status and counts.""" + peer = _diff_element_to_node_diff_peer({"status": "UPDATED", "num_added": 1, "num_removed": 0, "num_updated": 2}) + + assert peer == {"action": "UPDATED", "summary": {"added": 1, "removed": 0, "updated": 2}} + + +def test_diff_element_to_node_diff_peer_with_peer_and_properties() -> None: + peer = _diff_element_to_node_diff_peer( + { + "status": "ADDED", + "num_added": 1, + "num_removed": 0, + "num_updated": 0, + "peer_id": "17fbadf0-6243-5d3c-43ee-167718ff8dac", + "peer_label": "Jonathan", + "properties": [ + { + "property_type": "IS_RELATED", + "status": "UPDATED", + "previous_value": "17fbadf0-634f-05a8-43e4-1677e744d4c0", + "new_value": "17fbadf0-6243-5d3c-43ee-167718ff8dac", + "previous_label": "Jane", + "new_label": "Jonathan", + } + ], + } + ) + + assert peer == { + "action": "ADDED", + "summary": {"added": 1, "removed": 0, "updated": 0}, + "peer_id": "17fbadf0-6243-5d3c-43ee-167718ff8dac", + "peer_label": "Jonathan", + "properties": [ + { + "property_type": "IS_RELATED", + "action": "UPDATED", + "previous_value": "17fbadf0-634f-05a8-43e4-1677e744d4c0", + "new_value": "17fbadf0-6243-5d3c-43ee-167718ff8dac", + "previous_label": "Jane", + "new_label": "Jonathan", + } + ], + } + + +def test_diff_element_to_node_diff_peer_defaults() -> None: + """Null counts coerce to 0, a null peer_label is kept, an empty properties list is dropped.""" + peer = _diff_element_to_node_diff_peer( + { + "status": "REMOVED", + "num_added": None, + "num_removed": None, + "num_updated": None, + "peer_id": "17fbadf0-634f-05a8-43e4-1677e744d4c0", + "peer_label": None, + "properties": [], + } + ) + + assert peer == { + "action": "REMOVED", + "summary": {"added": 0, "removed": 0, "updated": 0}, + "peer_id": "17fbadf0-634f-05a8-43e4-1677e744d4c0", + "peer_label": None, + } From e14dec208f9160895c85cdb5bf1403ad46be2415 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 7 Aug 2026 12:51:02 +0300 Subject: [PATCH 7/8] refactor: share peer field extraction between peer diffs and cardinality-one flattening Co-Authored-By: Claude Fable 5 --- infrahub_sdk/diff.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/infrahub_sdk/diff.py b/infrahub_sdk/diff.py index d932fb0f4..c43c26614 100644 --- a/infrahub_sdk/diff.py +++ b/infrahub_sdk/diff.py @@ -17,15 +17,18 @@ class NodeDiff(TypedDict): elements: list[NodeDiffElement] -class NodeDiffElement(TypedDict): +class NodeDiffPeerFields(TypedDict, total=False): + peer_id: str + peer_label: str | None + properties: list[NodeDiffProperty] + + +class NodeDiffElement(NodeDiffPeerFields): name: str element_type: str action: str summary: NodeDiffSummary peers: NotRequired[list[NodeDiffPeer]] - peer_id: NotRequired[str] - peer_label: NotRequired[str | None] - properties: NotRequired[list[NodeDiffProperty]] class NodeDiffSummary(TypedDict): @@ -34,12 +37,9 @@ class NodeDiffSummary(TypedDict): removed: int -class NodeDiffPeer(TypedDict): +class NodeDiffPeer(NodeDiffPeerFields): action: str summary: NodeDiffSummary - peer_id: NotRequired[str] - peer_label: NotRequired[str | None] - properties: NotRequired[list[NodeDiffProperty]] class NodeDiffProperty(TypedDict): @@ -117,6 +117,16 @@ def _diff_properties_to_node_diff_properties(property_dicts: list[dict[str, Any] ] +def _element_to_node_diff_peer_fields(element_dict: dict[str, Any]) -> NodeDiffPeerFields: + fields = NodeDiffPeerFields() + if "peer_id" in element_dict: + fields["peer_id"] = str(element_dict["peer_id"]) + fields["peer_label"] = element_dict.get("peer_label") + if element_dict.get("properties"): + fields["properties"] = _diff_properties_to_node_diff_properties(element_dict["properties"]) + return fields + + def _diff_element_to_node_diff_peer(element_dict: dict[str, Any]) -> NodeDiffPeer: peer_diff = NodeDiffPeer( action=str(element_dict.get("status")), @@ -126,11 +136,7 @@ def _diff_element_to_node_diff_peer(element_dict: dict[str, Any]) -> NodeDiffPee "updated": int(element_dict.get("num_updated") or 0), }, ) - if "peer_id" in element_dict: - peer_diff["peer_id"] = str(element_dict["peer_id"]) - peer_diff["peer_label"] = element_dict.get("peer_label") - if element_dict.get("properties"): - peer_diff["properties"] = _diff_properties_to_node_diff_properties(element_dict["properties"]) + peer_diff.update(_element_to_node_diff_peer_fields(element_dict)) return peer_diff @@ -170,12 +176,7 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> _diff_element_to_node_diff_peer(element_dict) for element_dict in element_dicts ] elif is_cardinality_one and len(element_dicts) == 1: - peer_diff = _diff_element_to_node_diff_peer(element_dicts[0]) - if "peer_id" in peer_diff: - relationship_diff["peer_id"] = peer_diff["peer_id"] - relationship_diff["peer_label"] = peer_diff.get("peer_label") - if "properties" in peer_diff: - relationship_diff["properties"] = peer_diff["properties"] + relationship_diff.update(_element_to_node_diff_peer_fields(element_dicts[0])) elif is_cardinality_one and element_dicts: # a cardinality-one diff normally has a single element; if the server # ever returns several, keep them all instead of flattening one From 2859aa032566e7f187c3afb0ba86bc99ee99ee0e Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Tue, 11 Aug 2026 11:27:22 +0300 Subject: [PATCH 8/8] revert: drop the include_properties replacement, keep the plain diff_data removal Nobody uses the value-level diff data, so the broken method is deleted without a replacement API. Co-Authored-By: Claude Fable 5 --- changelog/325.removed.md | 2 +- docs/docs/python-sdk/guides/branches.mdx | 6 +- .../sdk_ref/infrahub_sdk/client.mdx | 10 +- infrahub_sdk/client.py | 12 +- infrahub_sdk/diff.py | 98 +---- tests/integration/test_diff_tree.py | 87 ----- tests/unit/sdk/test_diff_summary.py | 340 +----------------- 7 files changed, 24 insertions(+), 531 deletions(-) delete mode 100644 tests/integration/test_diff_tree.py diff --git a/changelog/325.removed.md b/changelog/325.removed.md index 3f5dd881d..e79b0c1d4 100644 --- a/changelog/325.removed.md +++ b/changelog/325.removed.md @@ -1 +1 @@ -Removed `client.branch.diff_data()` from both the async and sync clients. The method relied on a `GET /api/diff/data` REST endpoint that does not exist in Infrahub, so every call returned a 404. Use `client.get_diff_tree()` instead, which now accepts `include_properties=True` to also return the value-level details of each change (previous/new value per attribute property, peer id/label per relationship element), or `client.get_diff_summary()` for the list of changed nodes; both use the `DiffTree` GraphQL query. +Removed `client.branch.diff_data()` from both the async and sync clients. The method relied on a `GET /api/diff/data` REST endpoint that does not exist in Infrahub, so every call returned a 404. Use `client.get_diff_tree()` to retrieve the full diff of a branch against its base branch, or `client.get_diff_summary()` for the list of changed nodes; both use the `DiffTree` GraphQL query. diff --git a/docs/docs/python-sdk/guides/branches.mdx b/docs/docs/python-sdk/guides/branches.mdx index 1cd60a4ae..ffe2049d7 100644 --- a/docs/docs/python-sdk/guides/branches.mdx +++ b/docs/docs/python-sdk/guides/branches.mdx @@ -110,7 +110,7 @@ The Python SDK provides multiple methods to manage the branches in an Infrahub i ## Getting the diff for a branch -Use `get_diff_tree` to retrieve the full diff of a branch compared to its base branch, including summary counts and the list of changed nodes. It returns `None` if no diff exists for the branch. Set `include_properties=True` to also retrieve the value-level details of each change (previous and new value per property). +Use `get_diff_tree` to retrieve the full diff of a branch compared to its base branch, including summary counts and the list of changed nodes. It returns `None` if no diff exists for the branch. @@ -118,7 +118,7 @@ Use `get_diff_tree` to retrieve the full diff of a branch compared to its base b ```python from infrahub_sdk import InfrahubClient client = InfrahubClient() - diff = await client.get_diff_tree(branch="new-branch", include_properties=True) + diff = await client.get_diff_tree(branch="new-branch") ``` @@ -127,7 +127,7 @@ Use `get_diff_tree` to retrieve the full diff of a branch compared to its base b ```python from infrahub_sdk import InfrahubClientSync client = InfrahubClientSync() - diff = client.get_diff_tree(branch="new-branch", include_properties=True) + diff = client.get_diff_tree(branch="new-branch") ``` diff --git a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx index 7d6346b23..1f783d46c 100644 --- a/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx +++ b/docs/docs/python-sdk/sdk_ref/infrahub_sdk/client.mdx @@ -403,14 +403,11 @@ get_diff_summary(self, branch: str, name: str | None = None, from_time: datetime #### `get_diff_tree` ```python -get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None, include_properties: bool = False) -> DiffTreeData | None +get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None) -> DiffTreeData | None ``` Get complete diff tree with metadata and nodes. -Set ``include_properties`` to True to also retrieve the value-level details -of each change (previous/new values per property). - Returns None if no diff exists. **Raises:** @@ -935,14 +932,11 @@ get_diff_summary(self, branch: str, name: str | None = None, from_time: datetime #### `get_diff_tree` ```python -get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None, include_properties: bool = False) -> DiffTreeData | None +get_diff_tree(self, branch: str, name: str | None = None, from_time: datetime | None = None, to_time: datetime | None = None, timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None) -> DiffTreeData | None ``` Get complete diff tree with metadata and nodes. -Set ``include_properties`` to True to also retrieve the value-level details -of each change (previous/new values per property). - Returns None if no diff exists. **Raises:** diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index 84ec803e9..baca5ec88 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -1841,20 +1841,16 @@ async def get_diff_tree( timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None, - include_properties: bool = False, ) -> DiffTreeData | None: """Get complete diff tree with metadata and nodes. - Set ``include_properties`` to True to also retrieve the value-level details - of each change (previous/new values per property). - Returns None if no diff exists. Raises: ValueError: If ``from_time`` is later than ``to_time``. """ - query = get_diff_tree_query(include_properties=include_properties) + query = get_diff_tree_query() input_data = {"branch_name": branch} if name: input_data["name"] = name @@ -3437,20 +3433,16 @@ def get_diff_tree( timeout: int | None = None, tracker: str | None = None, priority: Priority | None = None, - include_properties: bool = False, ) -> DiffTreeData | None: """Get complete diff tree with metadata and nodes. - Set ``include_properties`` to True to also retrieve the value-level details - of each change (previous/new values per property). - Returns None if no diff exists. Raises: ValueError: If ``from_time`` is later than ``to_time``. """ - query = get_diff_tree_query(include_properties=include_properties) + query = get_diff_tree_query() input_data = {"branch_name": branch} if name: input_data["name"] = name diff --git a/infrahub_sdk/diff.py b/infrahub_sdk/diff.py index c43c26614..fd4d9308d 100644 --- a/infrahub_sdk/diff.py +++ b/infrahub_sdk/diff.py @@ -17,13 +17,7 @@ class NodeDiff(TypedDict): elements: list[NodeDiffElement] -class NodeDiffPeerFields(TypedDict, total=False): - peer_id: str - peer_label: str | None - properties: list[NodeDiffProperty] - - -class NodeDiffElement(NodeDiffPeerFields): +class NodeDiffElement(TypedDict): name: str element_type: str action: str @@ -37,20 +31,11 @@ class NodeDiffSummary(TypedDict): removed: int -class NodeDiffPeer(NodeDiffPeerFields): +class NodeDiffPeer(TypedDict): action: str summary: NodeDiffSummary -class NodeDiffProperty(TypedDict): - property_type: str - action: str - previous_value: str | None - new_value: str | None - previous_label: str | None - new_label: str | None - - class DiffTreeData(TypedDict): num_added: int num_updated: int @@ -103,43 +88,6 @@ def get_diff_summary_query() -> str: """ -def _diff_properties_to_node_diff_properties(property_dicts: list[dict[str, Any]]) -> list[NodeDiffProperty]: - return [ - NodeDiffProperty( - property_type=str(property_dict.get("property_type")), - action=str(property_dict.get("status")), - previous_value=property_dict.get("previous_value"), - new_value=property_dict.get("new_value"), - previous_label=property_dict.get("previous_label"), - new_label=property_dict.get("new_label"), - ) - for property_dict in property_dicts - ] - - -def _element_to_node_diff_peer_fields(element_dict: dict[str, Any]) -> NodeDiffPeerFields: - fields = NodeDiffPeerFields() - if "peer_id" in element_dict: - fields["peer_id"] = str(element_dict["peer_id"]) - fields["peer_label"] = element_dict.get("peer_label") - if element_dict.get("properties"): - fields["properties"] = _diff_properties_to_node_diff_properties(element_dict["properties"]) - return fields - - -def _diff_element_to_node_diff_peer(element_dict: dict[str, Any]) -> NodeDiffPeer: - peer_diff = NodeDiffPeer( - action=str(element_dict.get("status")), - summary={ - "added": int(element_dict.get("num_added") or 0), - "removed": int(element_dict.get("num_removed") or 0), - "updated": int(element_dict.get("num_updated") or 0), - }, - ) - peer_diff.update(_element_to_node_diff_peer_fields(element_dict)) - return peer_diff - - def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> NodeDiff: element_diffs: list[NodeDiffElement] = [] if "attributes" in node_dict: @@ -154,8 +102,6 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> "updated": int(attr_dict.get("num_updated") or 0), }, ) - if attr_dict.get("properties"): - attr_diff["properties"] = _diff_properties_to_node_diff_properties(attr_dict["properties"]) element_diffs.append(attr_diff) if "relationships" in node_dict: for relationship_dict in node_dict["relationships"]: @@ -170,19 +116,19 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> "updated": int(relationship_dict.get("num_updated") or 0), }, ) - element_dicts = relationship_dict.get("elements") or [] if not is_cardinality_one and "elements" in relationship_dict: - relationship_diff["peers"] = [ - _diff_element_to_node_diff_peer(element_dict) for element_dict in element_dicts - ] - elif is_cardinality_one and len(element_dicts) == 1: - relationship_diff.update(_element_to_node_diff_peer_fields(element_dicts[0])) - elif is_cardinality_one and element_dicts: - # a cardinality-one diff normally has a single element; if the server - # ever returns several, keep them all instead of flattening one - relationship_diff["peers"] = [ - _diff_element_to_node_diff_peer(element_dict) for element_dict in element_dicts + peer_diffs = [ + NodeDiffPeer( + action=str(element_dict.get("status")), + summary={ + "added": int(element_dict.get("num_added") or 0), + "removed": int(element_dict.get("num_removed") or 0), + "updated": int(element_dict.get("num_updated") or 0), + }, + ) + for element_dict in relationship_dict["elements"] ] + relationship_diff["peers"] = peer_diffs element_diffs.append(relationship_diff) return NodeDiff( branch=branch_name, @@ -194,8 +140,8 @@ def diff_tree_node_to_node_diff(node_dict: dict[str, Any], branch_name: str) -> ) -def get_diff_tree_query(include_properties: bool = False) -> Query: - node_structure: dict[str, Any] = { +def get_diff_tree_query() -> Query: + node_structure = { "uuid": None, "kind": None, "status": None, @@ -226,20 +172,6 @@ def get_diff_tree_query(include_properties: bool = False) -> Query: }, } - if include_properties: - property_structure = { - "property_type": None, - "status": None, - "previous_value": None, - "new_value": None, - "previous_label": None, - "new_label": None, - } - node_structure["attributes"]["properties"] = property_structure - node_structure["relationships"]["elements"]["peer_id"] = None - node_structure["relationships"]["elements"]["peer_label"] = None - node_structure["relationships"]["elements"]["properties"] = property_structure - return Query( name="GetDiffTree", query={ diff --git a/tests/integration/test_diff_tree.py b/tests/integration/test_diff_tree.py deleted file mode 100644 index 07b723222..000000000 --- a/tests/integration/test_diff_tree.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from typing import TYPE_CHECKING - -import pytest - -from infrahub_sdk.testing.docker import TestInfrahubDockerClient -from infrahub_sdk.testing.schemas.animal import TESTING_DOG, SchemaAnimal - -if TYPE_CHECKING: - from infrahub_sdk import InfrahubClient - from infrahub_sdk.node import InfrahubNode - -DIFF_BRANCH = "diff-branch" -DIFF_NAME = "diff-branch-test-diff" -NEW_COLOR = "#000000" - - -class TestGetDiffTree(TestInfrahubDockerClient, SchemaAnimal): - @pytest.fixture(scope="class") - async def branch_with_changes( - self, - client: InfrahubClient, - load_schema: None, - person_liam: InfrahubNode, - person_sophia: InfrahubNode, - dog_rocky: InfrahubNode, - ) -> None: - """Create a branch, change an attribute and a relationship on it and compute the diff.""" - branch = await client.branch.create(branch_name=DIFF_BRANCH) - - dog = await client.get(kind=TESTING_DOG, id=dog_rocky.id, branch=DIFF_BRANCH) - dog.color.value = NEW_COLOR - dog.owner = person_liam.id - await dog.save() - - await client.create_diff( - branch=DIFF_BRANCH, - name=DIFF_NAME, - from_time=datetime.fromisoformat(branch.branched_from.replace("Z", "+00:00")), - to_time=datetime.now(timezone.utc), - ) - - async def test_get_diff_tree_summary_only( - self, client: InfrahubClient, branch_with_changes: None, dog_rocky: InfrahubNode - ) -> None: - """Without include_properties the diff tree only contains summary counts.""" - diff_tree = await client.get_diff_tree(branch=DIFF_BRANCH) - - assert diff_tree is not None - assert diff_tree["base_branch"] == "main" - assert diff_tree["diff_branch"] == DIFF_BRANCH - dog_node = next(node for node in diff_tree["nodes"] if node["id"] == dog_rocky.id) - assert dog_node["action"] == "UPDATED" - color_element = next(element for element in dog_node["elements"] if element["name"] == "color") - assert "properties" not in color_element - - async def test_get_diff_tree_with_properties( - self, - client: InfrahubClient, - branch_with_changes: None, - person_liam: InfrahubNode, - person_sophia: InfrahubNode, - dog_rocky: InfrahubNode, - ) -> None: - """With include_properties the diff tree contains previous/new values per change.""" - diff_tree = await client.get_diff_tree(branch=DIFF_BRANCH, include_properties=True) - - assert diff_tree is not None - dog_node = next(node for node in diff_tree["nodes"] if node["id"] == dog_rocky.id) - - color_element = next(element for element in dog_node["elements"] if element["name"] == "color") - assert color_element["element_type"] == "ATTRIBUTE" - color_value = next(prop for prop in color_element["properties"] if prop["property_type"] == "HAS_VALUE") - assert color_value["action"] == "UPDATED" - assert color_value["previous_value"] == "#784212" - assert color_value["new_value"] == NEW_COLOR - - owner_element = next(element for element in dog_node["elements"] if element["name"] == "owner") - assert owner_element["element_type"] == "RELATIONSHIP_ONE" - assert owner_element["peer_id"] in {person_liam.id, person_sophia.id} - assert "peer_label" in owner_element - owner_value = next(prop for prop in owner_element["properties"] if prop["property_type"] == "IS_RELATED") - assert owner_value["action"] == "UPDATED" - assert owner_value["previous_value"] == person_sophia.id - assert owner_value["new_value"] == person_liam.id diff --git a/tests/unit/sdk/test_diff_summary.py b/tests/unit/sdk/test_diff_summary.py index 31283586d..fddd388b0 100644 --- a/tests/unit/sdk/test_diff_summary.py +++ b/tests/unit/sdk/test_diff_summary.py @@ -4,11 +4,7 @@ from pytest_httpx import HTTPXMock from infrahub_sdk import InfrahubClient -from infrahub_sdk.diff import ( - _diff_element_to_node_diff_peer, # noqa: PLC2701 - diff_tree_node_to_node_diff, - get_diff_tree_query, -) +from infrahub_sdk.diff import get_diff_tree_query from tests.unit.sdk.conftest import BothClients client_types = ["standard", "sync"] @@ -397,337 +393,3 @@ async def test_get_diff_tree_time_validation(clients: BothClients, client_type: from_time=from_time, to_time=to_time, ) - - -def test_get_diff_tree_query_with_properties() -> None: - """Test that include_properties adds the value-level fields to the query.""" - rendered_without = get_diff_tree_query().render() - assert "properties" not in rendered_without - assert "previous_value" not in rendered_without - - rendered = get_diff_tree_query(include_properties=True).render() - assert "properties" in rendered - assert "property_type" in rendered - assert "previous_value" in rendered - assert "new_value" in rendered - assert "previous_label" in rendered - assert "new_label" in rendered - assert "peer_id" in rendered - assert "peer_label" in rendered - - -@pytest.fixture -async def mock_diff_tree_with_properties(httpx_mock: HTTPXMock, client: InfrahubClient) -> HTTPXMock: - """Mock diff tree response including value-level property details.""" - response = { - "data": { - "DiffTree": { - "num_added": 0, - "num_updated": 2, - "num_removed": 0, - "num_conflicts": 0, - "to_time": "2025-11-14T12:00:00Z", - "from_time": "2025-11-01T00:00:00Z", - "base_branch": "main", - "diff_branch": "feature-branch", - "name": None, - "nodes": [ - { - "attributes": [ - { - "name": "name", - "num_added": 0, - "num_removed": 0, - "num_updated": 1, - "status": "UPDATED", - "properties": [ - { - "property_type": "HAS_VALUE", - "status": "UPDATED", - "previous_value": "John", - "new_value": "Jonathan", - "previous_label": None, - "new_label": None, - } - ], - } - ], - "kind": "TestPerson", - "label": "Jonathan", - "num_added": 0, - "num_removed": 0, - "num_updated": 1, - "relationships": [ - { - "cardinality": "MANY", - "name": "cars", - "num_added": 1, - "num_removed": 0, - "num_updated": 0, - "status": "UPDATED", - "elements": [ - { - "status": "ADDED", - "num_added": 3, - "num_removed": 0, - "num_updated": 0, - "peer_id": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", - "peer_label": "nolt #444444", - "properties": [ - { - "property_type": "IS_RELATED", - "status": "ADDED", - "previous_value": None, - "new_value": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", - "previous_label": None, - "new_label": "nolt #444444", - } - ], - } - ], - } - ], - "status": "UPDATED", - "uuid": "17fbadf0-6243-5d3c-43ee-167718ff8dac", - }, - { - "attributes": [], - "kind": "TestCar", - "label": "nolt #444444", - "num_added": 0, - "num_removed": 0, - "num_updated": 1, - "relationships": [ - { - "cardinality": "ONE", - "name": "owner", - "num_added": 0, - "num_removed": 0, - "num_updated": 1, - "status": "UPDATED", - "elements": [ - { - "status": "UPDATED", - "num_added": 0, - "num_removed": 0, - "num_updated": 1, - "peer_id": "17fbadf0-634f-05a8-43e4-1677e744d4c0", - "peer_label": "Jane", - "properties": [ - { - "property_type": "IS_RELATED", - "status": "UPDATED", - "previous_value": "17fbadf0-634f-05a8-43e4-1677e744d4c0", - "new_value": "17fbadf0-6243-5d3c-43ee-167718ff8dac", - "previous_label": "Jane", - "new_label": "Jonathan", - } - ], - } - ], - } - ], - "status": "UPDATED", - "uuid": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", - }, - ], - } - } - } - - httpx_mock.add_response( - method="POST", - json=response, - match_headers={"X-Infrahub-Tracker": "query-difftree-properties"}, - ) - return httpx_mock - - -@pytest.mark.parametrize("client_type", client_types) -async def test_get_diff_tree_with_properties( - clients: BothClients, mock_diff_tree_with_properties: HTTPXMock, client_type: str -) -> None: - """Test get_diff_tree with include_properties returns value-level details.""" - if client_type == "standard": - diff_tree = await clients.standard.get_diff_tree( - branch="feature-branch", - tracker="query-difftree-properties", - include_properties=True, - ) - else: - diff_tree = clients.sync.get_diff_tree( - branch="feature-branch", - tracker="query-difftree-properties", - include_properties=True, - ) - - assert diff_tree is not None - person = next(node for node in diff_tree["nodes"] if node["kind"] == "TestPerson") - name_element = next(element for element in person["elements"] if element["name"] == "name") - assert name_element["properties"] == [ - { - "property_type": "HAS_VALUE", - "action": "UPDATED", - "previous_value": "John", - "new_value": "Jonathan", - "previous_label": None, - "new_label": None, - } - ] - - cars_element = next(element for element in person["elements"] if element["name"] == "cars") - assert cars_element["element_type"] == "RELATIONSHIP_MANY" - assert cars_element["peers"] == [ - { - "action": "ADDED", - "summary": {"added": 3, "removed": 0, "updated": 0}, - "peer_id": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", - "peer_label": "nolt #444444", - "properties": [ - { - "property_type": "IS_RELATED", - "action": "ADDED", - "previous_value": None, - "new_value": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", - "previous_label": None, - "new_label": "nolt #444444", - } - ], - } - ] - - car = next(node for node in diff_tree["nodes"] if node["kind"] == "TestCar") - owner_element = next(element for element in car["elements"] if element["name"] == "owner") - assert owner_element["element_type"] == "RELATIONSHIP_ONE" - assert owner_element["peer_id"] == "17fbadf0-634f-05a8-43e4-1677e744d4c0" - assert owner_element["peer_label"] == "Jane" - assert owner_element["properties"] == [ - { - "property_type": "IS_RELATED", - "action": "UPDATED", - "previous_value": "17fbadf0-634f-05a8-43e4-1677e744d4c0", - "new_value": "17fbadf0-6243-5d3c-43ee-167718ff8dac", - "previous_label": "Jane", - "new_label": "Jonathan", - } - ] - - -def test_cardinality_one_with_multiple_elements_keeps_all_peers() -> None: - """A cardinality-one relationship with several elements must not flatten to a single peer.""" - node_dict = { - "uuid": "17fbadf0-6637-4fa2-43e6-1677ea170e0f", - "kind": "TestCar", - "status": "UPDATED", - "label": "nolt #444444", - "attributes": [], - "relationships": [ - { - "cardinality": "ONE", - "name": "owner", - "num_added": 1, - "num_removed": 1, - "num_updated": 0, - "status": "UPDATED", - "elements": [ - { - "status": "REMOVED", - "num_added": 0, - "num_removed": 1, - "num_updated": 0, - "peer_id": "17fbadf0-634f-05a8-43e4-1677e744d4c0", - "peer_label": "Jane", - "properties": [], - }, - { - "status": "ADDED", - "num_added": 1, - "num_removed": 0, - "num_updated": 0, - "peer_id": "17fbadf0-6243-5d3c-43ee-167718ff8dac", - "peer_label": "Jonathan", - "properties": [], - }, - ], - } - ], - } - - node_diff = diff_tree_node_to_node_diff(node_dict=node_dict, branch_name="branch2") - - owner_element = node_diff["elements"][0] - assert owner_element["element_type"] == "RELATIONSHIP_ONE" - assert "peer_id" not in owner_element - assert [(peer["peer_id"], peer["action"]) for peer in owner_element["peers"]] == [ - ("17fbadf0-634f-05a8-43e4-1677e744d4c0", "REMOVED"), - ("17fbadf0-6243-5d3c-43ee-167718ff8dac", "ADDED"), - ] - - -def test_diff_element_to_node_diff_peer_counts_only() -> None: - """Elements from the summary query carry only a status and counts.""" - peer = _diff_element_to_node_diff_peer({"status": "UPDATED", "num_added": 1, "num_removed": 0, "num_updated": 2}) - - assert peer == {"action": "UPDATED", "summary": {"added": 1, "removed": 0, "updated": 2}} - - -def test_diff_element_to_node_diff_peer_with_peer_and_properties() -> None: - peer = _diff_element_to_node_diff_peer( - { - "status": "ADDED", - "num_added": 1, - "num_removed": 0, - "num_updated": 0, - "peer_id": "17fbadf0-6243-5d3c-43ee-167718ff8dac", - "peer_label": "Jonathan", - "properties": [ - { - "property_type": "IS_RELATED", - "status": "UPDATED", - "previous_value": "17fbadf0-634f-05a8-43e4-1677e744d4c0", - "new_value": "17fbadf0-6243-5d3c-43ee-167718ff8dac", - "previous_label": "Jane", - "new_label": "Jonathan", - } - ], - } - ) - - assert peer == { - "action": "ADDED", - "summary": {"added": 1, "removed": 0, "updated": 0}, - "peer_id": "17fbadf0-6243-5d3c-43ee-167718ff8dac", - "peer_label": "Jonathan", - "properties": [ - { - "property_type": "IS_RELATED", - "action": "UPDATED", - "previous_value": "17fbadf0-634f-05a8-43e4-1677e744d4c0", - "new_value": "17fbadf0-6243-5d3c-43ee-167718ff8dac", - "previous_label": "Jane", - "new_label": "Jonathan", - } - ], - } - - -def test_diff_element_to_node_diff_peer_defaults() -> None: - """Null counts coerce to 0, a null peer_label is kept, an empty properties list is dropped.""" - peer = _diff_element_to_node_diff_peer( - { - "status": "REMOVED", - "num_added": None, - "num_removed": None, - "num_updated": None, - "peer_id": "17fbadf0-634f-05a8-43e4-1677e744d4c0", - "peer_label": None, - "properties": [], - } - ) - - assert peer == { - "action": "REMOVED", - "summary": {"added": 0, "removed": 0, "updated": 0}, - "peer_id": "17fbadf0-634f-05a8-43e4-1677e744d4c0", - "peer_label": None, - }