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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion infrahub_sdk/ctl/object/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ async def resolve_node(
if node is not None:
return node

raise NodeNotFoundError(node_type=kind, identifier={"id": [identifier]}, branch_name=branch)
raise NodeNotFoundError(
branch_name=branch or client.default_branch,
node_type=kind,
identifier={"id": [identifier]},
)


def prepare_relationship_data(data: dict[str, Any], schema: MainSchemaTypesAPI) -> dict[str, Any]:
Expand Down
14 changes: 7 additions & 7 deletions infrahub_sdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,22 +71,22 @@ def __init__(self, message: str | None = None) -> None:
class NodeNotFoundError(Error):
def __init__(
self,
identifier: Mapping[str, list[str]],
*,
branch_name: str,
node_type: str,
identifier: Mapping[str, list[str]] | str,
message: str = "Unable to find the node in the database.",
branch_name: str | None = None,
node_type: str | None = None,
) -> None:
self.node_type = node_type or "unknown"
self.identifier = identifier
self.branch_name = branch_name

self.node_type = node_type
self.identifier = identifier
self.message = message
super().__init__(self.message)

def __str__(self) -> str:
return f"""
{self.message}
{self.branch_name} | {self.node_type} | {self.identifier}
Branch: {self.branch_name} | Kind: {self.node_type} | Identifier: {self.identifier}
"""


Expand Down
37 changes: 25 additions & 12 deletions infrahub_sdk/file_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,13 @@ def prepare_upload_sync(content: bytes | Path | BinaryIO | None, name: str | Non
return PreparedFile(file_object=cast("BinaryIO", content), filename=filename, should_close=False)

@staticmethod
def handle_error_response(exc: httpx.HTTPStatusError) -> None:
def handle_error_response(exc: httpx.HTTPStatusError, branch: str, node_id: str) -> None:
"""Handle HTTP error responses for file operations.

Args:
exc: The HTTP status error from httpx.
branch: The branch name used for the request.
node_id: The ID of the FileObject node being accessed.

Raises:
AuthenticationError: If authentication fails (401/403).
Expand All @@ -114,15 +116,22 @@ def handle_error_response(exc: httpx.HTTPStatusError) -> None:
if exc.response.status_code == 404:
response = exc.response.json()
detail = response.get("detail", "File not found")
raise NodeNotFoundError(node_type="FileObject", identifier=detail) from exc
raise NodeNotFoundError(
branch_name=branch,
node_type="FileObject",
identifier={"id": [node_id]},
message=detail,
) from exc
raise exc

@staticmethod
def handle_response(resp: httpx.Response) -> bytes:
def handle_response(resp: httpx.Response, branch: str, node_id: str) -> bytes:
"""Handle the HTTP response and return file content as bytes.

Args:
resp: The HTTP response from httpx.
branch: The branch name used for the request.
node_id: The ID of the FileObject node being accessed.

Returns:
The file content as bytes.
Expand All @@ -134,7 +143,7 @@ def handle_response(resp: httpx.Response) -> bytes:
try:
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
FileHandlerBase.handle_error_response(exc=exc)
FileHandlerBase.handle_error_response(exc=exc, branch=branch, node_id=node_id)
return resp.content


Expand Down Expand Up @@ -198,22 +207,24 @@ async def download(self, node_id: str, branch: str | None, dest: Path | None = N
url = self._build_url(node_id=node_id, branch=effective_branch)

if dest is not None:
return await self._stream_to_file(url=url, dest=dest)
return await self._stream_to_file(url=url, dest=dest, branch=effective_branch, node_id=node_id)

try:
resp = await self._client._get(url=url)
except ServerNotReachableError:
self._client.log.error(f"Unable to connect to {self._client.address}")
raise

return self.handle_response(resp=resp)
return self.handle_response(resp=resp, branch=effective_branch, node_id=node_id)

async def _stream_to_file(self, url: str, dest: Path) -> int:
async def _stream_to_file(self, url: str, dest: Path, branch: str, node_id: str) -> int:
"""Stream download directly to a file without loading into memory.

Args:
url: The URL to download from.
dest: The destination path to write to.
branch: The branch name used for the request.
node_id: The ID of the FileObject node being downloaded.

Returns:
The number of bytes written to the file.
Expand All @@ -230,7 +241,7 @@ async def _stream_to_file(self, url: str, dest: Path) -> int:
except httpx.HTTPStatusError as exc:
# Need to read the response body for error details
await resp.aread()
self.handle_error_response(exc=exc)
self.handle_error_response(exc=exc, branch=branch, node_id=node_id)

bytes_written = 0
async with await anyio.Path(dest).open("wb") as f:
Expand Down Expand Up @@ -303,22 +314,24 @@ def download(self, node_id: str, branch: str | None, dest: Path | None = None) -
url = self._build_url(node_id=node_id, branch=effective_branch)

if dest is not None:
return self._stream_to_file(url=url, dest=dest)
return self._stream_to_file(url=url, dest=dest, branch=effective_branch, node_id=node_id)

try:
resp = self._client._get(url=url)
except ServerNotReachableError:
self._client.log.error(f"Unable to connect to {self._client.address}")
raise

return self.handle_response(resp=resp)
return self.handle_response(resp=resp, branch=effective_branch, node_id=node_id)

def _stream_to_file(self, url: str, dest: Path) -> int:
def _stream_to_file(self, url: str, dest: Path, branch: str, node_id: str) -> int:
"""Stream download directly to a file without loading into memory.

Args:
url: The URL to download from.
dest: The destination path to write to.
branch: The branch name used for the request.
node_id: The ID of the FileObject node being downloaded.

Returns:
The number of bytes written to the file.
Expand All @@ -335,7 +348,7 @@ def _stream_to_file(self, url: str, dest: Path) -> int:
except httpx.HTTPStatusError as exc:
# Need to read the response body for error details
resp.read()
self.handle_error_response(exc=exc)
self.handle_error_response(exc=exc, branch=branch, node_id=node_id)

bytes_written = 0
with dest.open("wb") as f:
Expand Down
16 changes: 15 additions & 1 deletion infrahub_sdk/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,15 @@ def get(

if kind and found_invalid:
raise NodeInvalidError(
branch_name=self.branch_name,
node_type=kind_name or "unknown",
identifier={"key": [key] if isinstance(key, str) else key},
message=f"Found a node of a different kind instead of {kind} for key {key!r} in the store ({self.branch_name})",
)

raise NodeNotFoundError(
branch_name=self.branch_name,
node_type=kind_name or "unknown",
identifier={"key": [key] if isinstance(key, str) else key},
message=f"Unable to find the node {key!r} in the store ({self.branch_name})",
)
Expand All @@ -111,13 +115,16 @@ def _get_by_internal_id(
) -> InfrahubNode | InfrahubNodeSync | CoreNode | CoreNodeSync:
if internal_id not in self._objs:
raise NodeNotFoundError(
branch_name=self.branch_name,
node_type=kind or "unknown",
identifier={"internal_id": [internal_id]},
message=f"Unable to find the node {internal_id!r} in the store ({self.branch_name})",
)

node = self._objs[internal_id]
if kind and kind not in node.get_all_kinds():
raise NodeInvalidError(
branch_name=self.branch_name,
node_type=kind,
identifier={"internal_id": [internal_id]},
message=f"Found a node of kind {node.get_kind()} instead of {kind} for internal_id {internal_id!r} in the store ({self.branch_name})",
Expand All @@ -130,6 +137,8 @@ def _get_by_key(
) -> InfrahubNode | InfrahubNodeSync | CoreNode | CoreNodeSync:
if key not in self._keys:
raise NodeNotFoundError(
branch_name=self.branch_name,
node_type=kind or "unknown",
identifier={"key": [key]},
message=f"Unable to find the node {key!r} in the store ({self.branch_name})",
)
Expand All @@ -138,6 +147,7 @@ def _get_by_key(

if kind and node.get_kind() != kind:
raise NodeInvalidError(
branch_name=self.branch_name,
node_type=kind,
identifier={"key": [key]},
message=f"Found a node of kind {node.get_kind()} instead of {kind} for key {key!r} in the store ({self.branch_name})",
Expand All @@ -148,13 +158,16 @@ def _get_by_key(
def _get_by_id(self, id: str, kind: str | None = None) -> InfrahubNode | InfrahubNodeSync | CoreNode | CoreNodeSync:
if id not in self._uuids:
raise NodeNotFoundError(
branch_name=self.branch_name,
node_type=kind or "unknown",
identifier={"id": [id]},
message=f"Unable to find the node {id!r} in the store ({self.branch_name})",
)

node = self._get_by_internal_id(self._uuids[id])
if kind and kind not in node.get_all_kinds():
raise NodeInvalidError(
branch_name=self.branch_name,
node_type=kind,
identifier={"id": [id]},
message=f"Found a node of kind {node.get_kind()} instead of {kind} for id {id!r} in the store ({self.branch_name})",
Expand All @@ -172,7 +185,8 @@ def _get_by_hfid(
node_hfid = [hfid] if isinstance(hfid, str) else hfid

exception_to_raise_if_not_found = NodeNotFoundError(
node_type=node_kind,
branch_name=self.branch_name,
node_type=node_kind or "unknown",
identifier={"hfid": node_hfid},
message=f"Unable to find the node {hfid!r} in the store ({self.branch_name})",
)
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/sdk/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

import pytest

from infrahub_sdk.exceptions import NodeInvalidError, NodeNotFoundError


def test_node_not_found_error_default_message_format() -> None:
error = NodeNotFoundError(
branch_name="main",
node_type="InfraDevice",
identifier={"name__value": ["bad-device404"]},
)

rendered = str(error)
assert "Unable to find the node in the database." in rendered
assert "Branch: main" in rendered
assert "Kind: InfraDevice" in rendered
assert "Identifier: {'name__value': ['bad-device404']}" in rendered


def test_node_not_found_error_custom_message_format() -> None:
error = NodeNotFoundError(
branch_name="feature",
node_type="InfraInterface",
identifier={"id": ["abc123"]},
message="Unable to find the node in the store.",
)

rendered = str(error)
assert "Unable to find the node in the store." in rendered
assert "Branch: feature | Kind: InfraInterface | Identifier: {'id': ['abc123']}" in rendered


def test_node_not_found_error_requires_keyword_arguments() -> None:
with pytest.raises(TypeError, match="positional argument"):
NodeNotFoundError("main", "InfraDevice", {"id": ["abc"]}) # type: ignore[misc]


def test_node_not_found_error_requires_all_fields() -> None:
with pytest.raises(TypeError, match="branch_name"):
NodeNotFoundError(node_type="InfraDevice", identifier={"id": ["abc"]}) # type: ignore[call-arg]

with pytest.raises(TypeError, match="node_type"):
NodeNotFoundError(branch_name="main", identifier={"id": ["abc"]}) # type: ignore[call-arg]

with pytest.raises(TypeError, match="identifier"):
NodeNotFoundError(branch_name="main", node_type="InfraDevice") # type: ignore[call-arg]


def test_node_invalid_error_inherits_signature() -> None:
error = NodeInvalidError(
branch_name="main",
node_type="InfraDevice",
identifier={"id": ["abc"]},
message="Found a node of a different kind",
)

assert isinstance(error, NodeNotFoundError)
rendered = str(error)
assert "Branch: main | Kind: InfraDevice | Identifier: {'id': ['abc']}" in rendered
assert "Found a node of a different kind" in rendered
33 changes: 19 additions & 14 deletions tests/unit/sdk/test_file_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,49 +171,54 @@ def test_handle_error_response_401() -> None:
response = httpx.Response(status_code=401, json={"errors": [{"message": "Invalid token"}]})
exc = httpx.HTTPStatusError(message="Unauthorized", request=httpx.Request("GET", "http://test"), response=response)

with pytest.raises(AuthenticationError) as excinfo:
FileHandlerBase.handle_error_response(exc=exc)

assert "Invalid token" in str(excinfo.value)
with pytest.raises(AuthenticationError, match="Invalid token"):
FileHandlerBase.handle_error_response(exc=exc, branch="main", node_id=NODE_ID)


def test_handle_error_response_403() -> None:
"""Test handling 403 forbidden error."""
response = httpx.Response(status_code=403, json={"errors": [{"message": "Access denied"}]})
exc = httpx.HTTPStatusError(message="Forbidden", request=httpx.Request("GET", "http://test"), response=response)

with pytest.raises(AuthenticationError) as excinfo:
FileHandlerBase.handle_error_response(exc=exc)

assert "Access denied" in str(excinfo.value)
with pytest.raises(AuthenticationError, match="Access denied"):
FileHandlerBase.handle_error_response(exc=exc, branch="main", node_id=NODE_ID)


def test_handle_error_response_404() -> None:
"""Test handling 404 not found error."""
response = httpx.Response(status_code=404, json={"detail": "File not found with ID abc123"})
exc = httpx.HTTPStatusError(message="Not Found", request=httpx.Request("GET", "http://test"), response=response)

with pytest.raises(NodeNotFoundError) as excinfo:
FileHandlerBase.handle_error_response(exc=exc)
with pytest.raises(NodeNotFoundError, match="File not found with ID abc123") as excinfo:
FileHandlerBase.handle_error_response(exc=exc, branch="main", node_id=NODE_ID)

assert "File not found with ID abc123" in str(excinfo.value)
error = excinfo.value
assert error.branch_name == "main"
assert error.node_type == "FileObject"
assert error.identifier == {"id": [NODE_ID]}
assert error.message == "File not found with ID abc123"
rendered = str(error)
assert "Branch: main" in rendered
assert "Kind: FileObject" in rendered
assert f"Identifier: {{'id': ['{NODE_ID}']}}" in rendered
assert "File not found with ID abc123" in rendered


def test_handle_error_response_500() -> None:
"""Test handling 500 server error (re-raises)."""
response = httpx.Response(status_code=500, json={"error": "Internal server error"})
exc = httpx.HTTPStatusError(message="Server Error", request=httpx.Request("GET", "http://test"), response=response)

with pytest.raises(httpx.HTTPStatusError):
FileHandlerBase.handle_error_response(exc=exc)
with pytest.raises(httpx.HTTPStatusError, match="Server Error"):
FileHandlerBase.handle_error_response(exc=exc, branch="main", node_id=NODE_ID)


def test_handle_response_success() -> None:
"""Test handling successful response."""
request = httpx.Request("GET", "http://test")
response = httpx.Response(status_code=200, content=FILE_CONTENT_BYTES, request=request)

result = FileHandlerBase.handle_response(resp=response)
result = FileHandlerBase.handle_response(resp=response, branch="main", node_id=NODE_ID)

assert result == FILE_CONTENT_BYTES

Expand Down