diff --git a/CHANGELOG.md b/CHANGELOG.md index ada1789..cad316d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ This project uses [*towncrier*](https://towncrier.readthedocs.io/) and the chang +## [1.4.1](https://github.com/opsmill/infrahub-sdk-python/tree/v1.3.0) - 2025-01-05 + +### Fixed + +- Fixes an issue introduced in 1.4 that would prevent a node with relationship of cardinality one from being updated. + ## [1.4.0](https://github.com/opsmill/infrahub-sdk-python/tree/v1.4.0) - 2025-01-03 ### Changed diff --git a/infrahub_sdk/node.py b/infrahub_sdk/node.py index ce9865e..6a81835 100644 --- a/infrahub_sdk/node.py +++ b/infrahub_sdk/node.py @@ -904,7 +904,7 @@ def _strip_unmodified_dict(data: dict, original_data: dict, variables: dict, ite variables.pop(variable_key) # TODO: I do not feel _great_ about this - if not data_item and data_item != []: + if not data_item and data_item != [] and item in data: data.pop(item) def _strip_unmodified(self, data: dict, variables: dict) -> tuple[dict, dict]: diff --git a/infrahub_sdk/testing/schemas/car_person.py b/infrahub_sdk/testing/schemas/car_person.py index ce3f2f6..2da7ab4 100644 --- a/infrahub_sdk/testing/schemas/car_person.py +++ b/infrahub_sdk/testing/schemas/car_person.py @@ -170,6 +170,12 @@ async def person_joe(self, client: InfrahubClient, person_joe_data: TestingPerso await obj.save() return obj + @pytest.fixture(scope="class") + async def person_jane(self, client: InfrahubClient, person_jane_data: TestingPersonData) -> InfrahubNode: + obj = await client.create(**asdict(person_jane_data)) + await obj.save() + return obj + @pytest.fixture(scope="class") async def manufacturer_mercedes( self, client: InfrahubClient, manufacturer_mercedes_data: TestingManufacturerData @@ -202,6 +208,12 @@ async def tag_red(self, client: InfrahubClient) -> InfrahubNode: await obj.save() return obj + @pytest.fixture(scope="class") + async def tag_green(self, client: InfrahubClient) -> InfrahubNode: + obj = await client.create(kind=BUILTIN_TAG, name="Green") + await obj.save() + return obj + async def create_persons(self, client: InfrahubClient, branch: str) -> list[InfrahubNode]: john = await client.create(kind=TESTING_PERSON, name="John Doe", branch=branch) await john.save() diff --git a/pyproject.toml b/pyproject.toml index 6218678..c63005a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "infrahub-sdk" -version = "1.4.0" +version = "1.4.1" requires-python = ">=3.9" [tool.poetry] name = "infrahub-sdk" -version = "1.4.0" +version = "1.4.1" description = "Python Client to interact with Infrahub" authors = ["OpsMill "] readme = "README.md" diff --git a/tests/integration/test_node.py b/tests/integration/test_node.py index e860948..a0d8e89 100644 --- a/tests/integration/test_node.py +++ b/tests/integration/test_node.py @@ -143,9 +143,11 @@ async def test_node_update( initial_schema: None, manufacturer_mercedes, person_joe, + person_jane, car_golf, tag_blue, tag_red, + tag_green, ): car_golf.color.value = "White" await car_golf.tags.fetch() @@ -153,38 +155,19 @@ async def test_node_update( car_golf.tags.add(tag_red.id) await car_golf.save() - node_after = await client.get(kind=TESTING_CAR, id=car_golf.id) - assert node_after.color.value == "White" - await node_after.tags.fetch() - assert len(node_after.tags.peers) == 2 + car2 = await client.get(kind=TESTING_CAR, id=car_golf.id) + assert car2.color.value == "White" + await car2.tags.fetch() + assert len(car2.tags.peers) == 2 - # async def test_node_update_2( - # self, - # db: InfrahubDatabase, - # client: InfrahubClient, - # init_db_base, - # load_builtin_schema, - # tag_green: Node, - # tag_red: Node, - # tag_blue: Node, - # gqlquery02: Node, - # repo99: Node, - # ): - # node = await client.get(kind="CoreGraphQLQuery", name__value="query02") - # assert node.id is not None + car2.owner = person_jane.id + car2.tags.add(tag_green.id) + car2.tags.remove(tag_red.id) + await car2.save() - # node.name.value = "query021" - # node.repository = repo99.id - # node.tags.add(tag_green.id) - # node.tags.remove(tag_red.id) - # await node.save() - - # nodedb = await NodeManager.get_one(id=node.id, db=db, include_owner=True, include_source=True) - # repodb = await nodedb.repository.get_peer(db=db) - # assert repodb.id == repo99.id - - # tags = await nodedb.tags.get(db=db) - # assert sorted([tag.peer_id for tag in tags]) == sorted([tag_green.id, tag_blue.id]) + car3 = await client.get(kind=TESTING_CAR, id=car_golf.id) + await car3.tags.fetch() + assert sorted([tag.id for tag in car3.tags.peers]) == sorted([tag_green.id, tag_blue.id]) # async def test_node_update_3_idempotency( # self, @@ -222,21 +205,6 @@ async def test_node_update( # assert "query" not in second_update["data"]["data"] # assert not second_update["variables"] - # async def test_convert_node( - # self, - # db: InfrahubDatabase, - # client: InfrahubClient, - # location_schema, - # init_db_base, - # load_builtin_schema, - # location_cdg: Node, - # ): - # data = await location_cdg.to_graphql(db=db) - # node = InfrahubNode(client=client, schema=location_schema, data=data) - - # # pylint: disable=no-member - # assert node.name.value == "cdg01" - # async def test_relationship_manager_errors_without_fetch(self, client: InfrahubClient, load_builtin_schema): # organization = await client.create("TestOrganization", name="organization-1") # await organization.save() diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index 1710fef..e802faa 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -373,6 +373,47 @@ async def location_data02_no_pagination(): @pytest.fixture async def location_data01(): + data = { + "node": { + "__typename": "BuiltinLocation", + "id": "llllllll-llll-llll-llll-llllllllllll", + "display_label": "dfw1", + "name": { + "value": "DFW", + }, + "description": { + "value": None, + }, + "type": { + "value": "SITE", + }, + "primary_tag": { + "node": { + "id": "rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr", + "display_label": "red", + "__typename": "BuiltinTag", + }, + }, + "tags": { + "count": 1, + "edges": [ + { + "node": { + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "display_label": "blue", + "__typename": "BuiltinTag", + }, + } + ], + }, + } + } + + return data + + +@pytest.fixture +async def location_data01_property(): data = { "node": { "__typename": "BuiltinLocation", @@ -438,6 +479,47 @@ async def location_data01(): @pytest.fixture async def location_data02(): + data = { + "node": { + "__typename": "BuiltinLocation", + "id": "llllllll-llll-llll-llll-llllllllllll", + "display_label": "dfw1", + "name": { + "value": "dfw1", + }, + "description": { + "value": None, + }, + "type": { + "value": "SITE", + }, + "primary_tag": { + "node": { + "id": "rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr", + "display_label": "red", + "__typename": "BuiltinTag", + }, + }, + "tags": { + "count": 1, + "edges": [ + { + "node": { + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "display_label": "blue", + "__typename": "BuiltinTag", + }, + } + ], + }, + } + } + + return data + + +@pytest.fixture +async def location_data02_property(): data = { "node": { "__typename": "BuiltinLocation", @@ -517,6 +599,28 @@ async def location_data02(): return data +@pytest.fixture +async def rfile_userdata01(): + return { + "name": {"value": "rfile01"}, + "template_path": {"value": "mytemplate.j2"}, + "query": {"id": "qqqqqqqq"}, + "repository": {"id": "rrrrrrrr"}, + "tags": [{"id": "t1t1t1t1"}, "t2t2t2t2"], + } + + +@pytest.fixture +async def rfile_userdata01_property(): + return { + "name": {"value": "rfile01", "is_protected": True, "source": "ffffffff"}, + "template_path": {"value": "mytemplate.j2"}, + "query": {"id": "qqqqqqqq", "source": "ffffffff", "owner": "ffffffff", "is_protected": True}, + "repository": {"id": "rrrrrrrr", "source": "ffffffff", "owner": "ffffffff"}, + "tags": [{"id": "t1t1t1t1"}, "t2t2t2t2"], + } + + @pytest.fixture async def tag_schema() -> NodeSchemaAPI: data = { diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index 4dec44a..34a8b4e 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -31,6 +31,11 @@ client_types = ["standard", "sync"] +WITH_PROPERTY = "with_property" +WITHOUT_PROPERTY = "without_property" +property_tests = [WITHOUT_PROPERTY, WITH_PROPERTY] + + SAFE_GRAPHQL_VALUES = [ pytest.param("", id="allow-empty"), pytest.param("user1", id="allow-normal"), @@ -176,15 +181,20 @@ async def test_init_node_data_user_with_relationships(client, location_schema: N assert node.primary_tag.id == "pppppppp" +@pytest.mark.parametrize("property_test", property_tests) @pytest.mark.parametrize("client_type", client_types) -async def test_init_node_data_graphql(client, location_schema: NodeSchemaAPI, location_data01, client_type): +async def test_init_node_data_graphql( + client, location_schema: NodeSchemaAPI, location_data01, location_data01_property, client_type, property_test +): + location_data = location_data01 if property_test == WITHOUT_PROPERTY else location_data01_property + if client_type == "standard": - node = InfrahubNode(client=client, schema=location_schema, data=location_data01) + node = InfrahubNode(client=client, schema=location_schema, data=location_data) else: - node = InfrahubNodeSync(client=client, schema=location_schema, data=location_data01) + node = InfrahubNodeSync(client=client, schema=location_schema, data=location_data) assert node.name.value == "DFW" - assert node.name.is_protected is True + assert node.name.is_protected is True if property_test == WITH_PROPERTY else node.name.is_protected is None assert node.description.value is None assert node.type.value == "SITE" @@ -1385,25 +1395,26 @@ async def test_create_input_data_with_relationships_03(clients, rfile_schema, cl } +@pytest.mark.parametrize("property_test", property_tests) @pytest.mark.parametrize("client_type", client_types) async def test_create_input_data_with_relationships_03_for_update_include_unmodified( - clients, rfile_schema, client_type + clients, + rfile_schema, + rfile_userdata01, + rfile_userdata01_property, + client_type, + property_test, ): - data = { - "name": {"value": "rfile01", "is_protected": True, "source": "ffffffff"}, - "template_path": {"value": "mytemplate.j2"}, - "query": {"id": "qqqqqqqq", "source": "ffffffff", "owner": "ffffffff", "is_protected": True}, - "repository": {"id": "rrrrrrrr", "source": "ffffffff", "owner": "ffffffff"}, - "tags": [{"id": "t1t1t1t1"}, "t2t2t2t2"], - } + rfile_userdata = rfile_userdata01 if property_test == WITHOUT_PROPERTY else rfile_userdata01_property if client_type == "standard": - node = InfrahubNode(client=clients.standard, schema=rfile_schema, data=data) + node = InfrahubNode(client=clients.standard, schema=rfile_schema, data=rfile_userdata) else: - node = InfrahubNodeSync(client=clients.sync, schema=rfile_schema, data=data) + node = InfrahubNodeSync(client=clients.sync, schema=rfile_schema, data=rfile_userdata) node.template_path.value = "my-changed-template.j2" - assert node._generate_input_data(exclude_unmodified=False)["data"] == { + + expected_result_with_property = { "data": { "name": { "is_protected": True, @@ -1422,28 +1433,46 @@ async def test_create_input_data_with_relationships_03_for_update_include_unmodi } } + expected_result_without_property = { + "data": { + "name": { + "value": "rfile01", + }, + "query": { + "id": "qqqqqqqq", + }, + "tags": [{"id": "t1t1t1t1"}, {"id": "t2t2t2t2"}], + "template_path": {"value": "my-changed-template.j2"}, + "repository": {"id": "rrrrrrrr"}, + } + } + + expected_result = ( + expected_result_without_property if property_test == WITHOUT_PROPERTY else expected_result_with_property + ) + assert node._generate_input_data(exclude_unmodified=False)["data"] == expected_result + +@pytest.mark.parametrize("property_test", property_tests) @pytest.mark.parametrize("client_type", client_types) async def test_create_input_data_with_relationships_03_for_update_exclude_unmodified( clients, rfile_schema, + rfile_userdata01, + rfile_userdata01_property, client_type, + property_test, ): - data = { - "name": {"value": "rfile01", "is_protected": True, "source": "ffffffff"}, - "template_path": {"value": "mytemplate.j2"}, - "query": {"id": "qqqqqqqq", "source": "ffffffff", "owner": "ffffffff", "is_protected": True}, - "repository": {"id": "rrrrrrrr", "source": "ffffffff", "owner": "ffffffff"}, - "tags": [{"id": "t1t1t1t1"}, "t2t2t2t2"], - } + """NOTE: Need to fix this test, the issue is tracked in https://github.com/opsmill/infrahub-sdk-python/issues/214.""" + rfile_userdata = rfile_userdata01 if property_test == WITHOUT_PROPERTY else rfile_userdata01_property if client_type == "standard": - node = InfrahubNode(client=clients.standard, schema=rfile_schema, data=data) + node = InfrahubNode(client=clients.standard, schema=rfile_schema, data=rfile_userdata) else: - node = InfrahubNodeSync(client=clients.sync, schema=rfile_schema, data=data) + node = InfrahubNodeSync(client=clients.sync, schema=rfile_schema, data=rfile_userdata) node.template_path.value = "my-changed-template.j2" - assert node._generate_input_data(exclude_unmodified=True)["data"] == { + expected_result_with_property = { "data": { "query": { "id": "qqqqqqqq", @@ -1456,6 +1485,18 @@ async def test_create_input_data_with_relationships_03_for_update_exclude_unmodi } } + expected_result_without_property = { + "data": { + "template_path": {"value": "my-changed-template.j2"}, + } + } + + expected_result = ( + expected_result_without_property if property_test == WITHOUT_PROPERTY else expected_result_with_property + ) + + assert node._generate_input_data(exclude_unmodified=True)["data"] == expected_result + @pytest.mark.parametrize("client_type", client_types) async def test_create_input_data_with_IPHost_attribute(client, ipaddress_schema, client_type): @@ -1485,24 +1526,29 @@ async def test_create_input_data_with_IPNetwork_attribute(client, ipnetwork_sche } +@pytest.mark.parametrize("property_test", property_tests) @pytest.mark.parametrize("client_type", client_types) async def test_update_input_data__with_relationships_01( client, location_schema, location_data01, + location_data01_property, tag_schema, tag_blue_data, tag_green_data, tag_red_data, client_type, + property_test, ): + location_data = location_data01 if property_test == WITHOUT_PROPERTY else location_data01_property + if client_type == "standard": - location = InfrahubNode(client=client, schema=location_schema, data=location_data01) + location = InfrahubNode(client=client, schema=location_schema, data=location_data) tag_green = InfrahubNode(client=client, schema=tag_schema, data=tag_green_data) tag_blue = InfrahubNode(client=client, schema=tag_schema, data=tag_blue_data) tag_red = InfrahubNode(client=client, schema=tag_schema, data=tag_red_data) else: - location = InfrahubNodeSync(client=client, schema=location_schema, data=location_data01) + location = InfrahubNodeSync(client=client, schema=location_schema, data=location_data) tag_green = InfrahubNodeSync(client=client, schema=tag_schema, data=tag_green_data) tag_blue = InfrahubNodeSync(client=client, schema=tag_schema, data=tag_blue_data) tag_red = InfrahubNodeSync(client=client, schema=tag_schema, data=tag_red_data) @@ -1511,7 +1557,16 @@ async def test_update_input_data__with_relationships_01( location.tags.extend([tag_green, tag_red]) location.tags.remove(tag_blue) - assert location._generate_input_data()["data"] == { + expected_result_without_property = { + "data": { + "id": "llllllll-llll-llll-llll-llllllllllll", + "name": {"value": "DFW"}, + "primary_tag": {"id": "gggggggg-gggg-gggg-gggg-gggggggggggg"}, + "tags": [{"id": "gggggggg-gggg-gggg-gggg-gggggggggggg"}, {"id": "rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr"}], + "type": {"value": "SITE"}, + }, + } + expected_result_with_property = { "data": { "id": "llllllll-llll-llll-llll-llllllllllll", "name": {"is_protected": True, "is_visible": True, "value": "DFW"}, @@ -1521,15 +1576,45 @@ async def test_update_input_data__with_relationships_01( }, } + expected_data = ( + expected_result_without_property if property_test == WITHOUT_PROPERTY else expected_result_with_property + ) + assert location._generate_input_data()["data"] == expected_data + +@pytest.mark.parametrize("property_test", property_tests) @pytest.mark.parametrize("client_type", client_types) -async def test_update_input_data_with_relationships_02(client, location_schema, location_data02, client_type): +async def test_update_input_data_with_relationships_02( + client, location_schema, location_data02, location_data02_property, client_type, property_test +): + location_data = location_data02 if property_test == WITHOUT_PROPERTY else location_data02_property + if client_type == "standard": - location = InfrahubNode(client=client, schema=location_schema, data=location_data02) + location = InfrahubNode(client=client, schema=location_schema, data=location_data) else: - location = InfrahubNodeSync(client=client, schema=location_schema, data=location_data02) + location = InfrahubNodeSync(client=client, schema=location_schema, data=location_data) + + expected_result_without_property = { + "data": { + "id": "llllllll-llll-llll-llll-llllllllllll", + "name": { + "value": "dfw1", + }, + "primary_tag": { + "id": "rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr", + }, + "tags": [ + { + "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + }, + ], + "type": { + "value": "SITE", + }, + }, + } - assert location._generate_input_data()["data"] == { + expected_result_with_property = { "data": { "id": "llllllll-llll-llll-llll-llllllllllll", "name": { @@ -1561,22 +1646,86 @@ async def test_update_input_data_with_relationships_02(client, location_schema, }, } + expected_result = ( + expected_result_without_property if property_test == WITHOUT_PROPERTY else expected_result_with_property + ) + + assert location._generate_input_data()["data"] == expected_result + +@pytest.mark.parametrize("property_test", property_tests) +@pytest.mark.parametrize("client_type", client_types) +async def test_update_input_data_with_relationships_02_exclude_unmodified( + client, location_schema, location_data02, location_data02_property, client_type, property_test +): + """NOTE Need to fix this test, issue is tracked in https://github.com/opsmill/infrahub-sdk-python/issues/214.""" + location_data = location_data02 if property_test == WITHOUT_PROPERTY else location_data02_property + + if client_type == "standard": + location = InfrahubNode(client=client, schema=location_schema, data=location_data) + else: + location = InfrahubNodeSync(client=client, schema=location_schema, data=location_data) + + expected_result_without_property = { + "data": { + "id": "llllllll-llll-llll-llll-llllllllllll", + }, + } + + expected_result_with_property = { + "data": { + "id": "llllllll-llll-llll-llll-llllllllllll", + "primary_tag": { + "_relation__is_protected": True, + "_relation__is_visible": True, + "_relation__source": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "id": "rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr", + }, + }, + } + + expected_result = ( + expected_result_without_property if property_test == WITHOUT_PROPERTY else expected_result_with_property + ) + + assert location._generate_input_data(exclude_unmodified=True)["data"] == expected_result + + +@pytest.mark.parametrize("property_test", property_tests) @pytest.mark.parametrize("client_type", client_types) async def test_update_input_data_empty_relationship( - client, location_schema, location_data01, tag_schema, tag_blue_data, client_type + client, + location_schema, + location_data01, + location_data01_property, + tag_schema, + tag_blue_data, + client_type, + property_test, ): + """TODO: investigate why name and type are being returned since they haven't been modified.""" + location_data = location_data01 if property_test == WITHOUT_PROPERTY else location_data01_property + if client_type == "standard": - location = InfrahubNode(client=client, schema=location_schema, data=location_data01) + location = InfrahubNode(client=client, schema=location_schema, data=location_data) tag_blue = InfrahubNode(client=client, schema=tag_schema, data=tag_blue_data) else: - location = InfrahubNodeSync(client=client, schema=location_schema, data=location_data01) + location = InfrahubNodeSync(client=client, schema=location_schema, data=location_data) tag_blue = InfrahubNode(client=client, schema=tag_schema, data=tag_blue_data) location.tags.remove(tag_blue) location.primary_tag = None - assert location._generate_input_data()["data"] == { + expected_result_without_property = { + "data": { + "id": "llllllll-llll-llll-llll-llllllllllll", + "name": {"value": "DFW"}, + # "primary_tag": None, + "tags": [], + "type": {"value": "SITE"}, + }, + } + expected_result_with_property = { "data": { "id": "llllllll-llll-llll-llll-llllllllllll", "name": {"is_protected": True, "is_visible": True, "value": "DFW"}, @@ -1586,6 +1735,11 @@ async def test_update_input_data_empty_relationship( }, } + expected_data = ( + expected_result_without_property if property_test == WITHOUT_PROPERTY else expected_result_with_property + ) + assert location._generate_input_data()["data"] == expected_data + @pytest.mark.parametrize("client_type", client_types) async def test_node_get_relationship_from_store(