diff --git a/changelog/473.fixed.md b/changelog/473.fixed.md new file mode 100644 index 00000000..22c5a5af --- /dev/null +++ b/changelog/473.fixed.md @@ -0,0 +1 @@ +JsonDecodeError now includes server response content in error message when JSON decoding fails, providing better debugging information for non-JSON server responses. \ No newline at end of file diff --git a/changelog/535.fixed.md b/changelog/535.fixed.md index fdbd499e..56c8fd43 100644 --- a/changelog/535.fixed.md +++ b/changelog/535.fixed.md @@ -1 +1 @@ -Fix branch handling in `_run_transform` and `execute_graphql_query` functions in Infrahubctl to use environment variables for branch management. \ No newline at end of file +Fix branch handling in `_run_transform` and `execute_graphql_query` functions in Infrahubctl to use environment variables for branch management. \ No newline at end of file diff --git a/changelog/549.fixed.md b/changelog/549.fixed.md new file mode 100644 index 00000000..1a4f975c --- /dev/null +++ b/changelog/549.fixed.md @@ -0,0 +1 @@ +Allow the ability to clear optional attributes by setting them to None if they have been mutated by the user. \ No newline at end of file diff --git a/infrahub_sdk/exceptions.py b/infrahub_sdk/exceptions.py index a8b1ef9b..d8982d8e 100644 --- a/infrahub_sdk/exceptions.py +++ b/infrahub_sdk/exceptions.py @@ -17,6 +17,8 @@ def __init__(self, message: str | None = None, content: str | None = None, url: self.url = url if not self.message and self.url: self.message = f"Unable to decode response as JSON data from {self.url}" + if self.content: + self.message += f". Server response: {self.content}" super().__init__(self.message) diff --git a/infrahub_sdk/node/attribute.py b/infrahub_sdk/node/attribute.py index 5ddc5cbe..9c752521 100644 --- a/infrahub_sdk/node/attribute.py +++ b/infrahub_sdk/node/attribute.py @@ -76,6 +76,8 @@ def _generate_input_data(self) -> dict | None: variables: dict[str, Any] = {} if self.value is None: + if self._schema.optional and self.value_has_been_mutated: + data["value"] = None return data if isinstance(self.value, str): diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index 5f0d7c2a..92749412 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -177,6 +177,48 @@ async def location_schema() -> NodeSchemaAPI: return NodeSchema(**data).convert_api() # type: ignore +@pytest.fixture +async def location_schema_with_dropdown() -> NodeSchemaAPI: + data = { + "name": "Location", + "namespace": "Builtin", + "default_filter": "name__value", + "attributes": [ + {"name": "name", "kind": "String", "unique": True}, + {"name": "description", "kind": "String", "optional": True}, + {"name": "type", "kind": "String"}, + { + "name": "status", + "kind": "Dropdown", + "optional": True, + "choices": [{"name": "active", "label": "Active"}, {"name": "planning", "label": "Planning"}], + }, + ], + "relationships": [ + { + "name": "tags", + "peer": "BuiltinTag", + "optional": True, + "cardinality": "many", + }, + { + "name": "primary_tag", + "peer": "BuiltinTag", + "optional": True, + "cardinality": "one", + }, + { + "name": "member_of_groups", + "peer": "CoreGroup", + "optional": True, + "cardinality": "many", + "kind": "Group", + }, + ], + } + return NodeSchema(**data).convert_api() # type: ignore + + @pytest.fixture async def schema_with_hfid() -> dict[str, NodeSchemaAPI]: data = { diff --git a/tests/unit/sdk/test_node.py b/tests/unit/sdk/test_node.py index c5c75052..e4192871 100644 --- a/tests/unit/sdk/test_node.py +++ b/tests/unit/sdk/test_node.py @@ -1370,6 +1370,34 @@ async def test_create_input_data(client, location_schema: NodeSchemaAPI, client_ } +@pytest.mark.parametrize("client_type", client_types) +async def test_create_input_data_with_dropdown(client, location_schema_with_dropdown, client_type) -> None: + """Validate input data including dropdown field""" + data = { + "name": {"value": "JFK1"}, + "description": {"value": "JFK Airport"}, + "type": {"value": "SITE"}, + "status": {"value": "active"}, + } + + if client_type == "standard": + node = InfrahubNode(client=client, schema=location_schema_with_dropdown, data=data) + else: + node = InfrahubNodeSync(client=client, schema=location_schema_with_dropdown, data=data) + + assert node.status.value == "active" + node.status = None + assert node._generate_input_data()["data"] == { + "data": { + "name": {"value": "JFK1"}, + "description": {"value": "JFK Airport"}, + "type": {"value": "SITE"}, + "status": {"value": None}, + "primary_tag": None, + } + } + + @pytest.mark.parametrize("client_type", client_types) async def test_create_input_data__with_relationships_02(client, location_schema, client_type) -> None: """Validate input data with variables that needs replacements""" diff --git a/tests/unit/sdk/test_utils.py b/tests/unit/sdk/test_utils.py index 99ef7e29..bc56bf98 100644 --- a/tests/unit/sdk/test_utils.py +++ b/tests/unit/sdk/test_utils.py @@ -1,11 +1,14 @@ +import json import tempfile import uuid from pathlib import Path +from unittest.mock import Mock import pytest from graphql import parse from whenever import Instant +from infrahub_sdk.exceptions import JsonDecodeError from infrahub_sdk.utils import ( base16decode, base16encode, @@ -13,6 +16,7 @@ base36encode, calculate_time_diff, compare_lists, + decode_json, deep_merge_dict, dict_hash, duplicates, @@ -227,3 +231,53 @@ def test_calculate_time_diff() -> None: time5 = Instant.now().subtract(hours=77, minutes=12, seconds=34).format_common_iso() assert calculate_time_diff(time5) == "3d and 5h ago" + + +def test_decode_json_success() -> None: + """Test decode_json with valid JSON response.""" + mock_response = Mock() + mock_response.json.return_value = {"status": "ok", "data": {"key": "value"}} + + result = decode_json(mock_response) + assert result == {"status": "ok", "data": {"key": "value"}} + + +def test_decode_json_failure_with_content() -> None: + """Test decode_json with invalid JSON response includes server content in error message.""" + mock_response = Mock() + mock_response.json.side_effect = json.decoder.JSONDecodeError("Invalid JSON", "document", 0) + mock_response.text = "Internal Server Error: Database connection failed" + mock_response.url = "https://example.com/api/graphql" + + with pytest.raises(JsonDecodeError) as exc_info: + decode_json(mock_response) + + error_message = str(exc_info.value) + assert "Unable to decode response as JSON data from https://example.com/api/graphql" in error_message + assert "Server response: Internal Server Error: Database connection failed" in error_message + + +def test_decode_json_failure_without_content() -> None: + """Test decode_json with invalid JSON response and no content.""" + mock_response = Mock() + mock_response.json.side_effect = json.decoder.JSONDecodeError("Invalid JSON", "document", 0) + mock_response.text = "" + mock_response.url = "https://example.com/api/graphql" + + with pytest.raises(JsonDecodeError) as exc_info: + decode_json(mock_response) + + error_message = str(exc_info.value) + assert "Unable to decode response as JSON data from https://example.com/api/graphql" in error_message + # Should not include server response part when content is empty + assert "Server response:" not in error_message + + +def test_json_decode_error_custom_message() -> None: + """Test JsonDecodeError with custom message does not override custom message.""" + custom_message = "Custom error message" + error = JsonDecodeError(message=custom_message, content="server error", url="https://example.com") + + assert str(error) == custom_message + assert error.content == "server error" + assert error.url == "https://example.com"