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
17 changes: 11 additions & 6 deletions sdks/python/edgequake/resources/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,14 @@ def create(self, rel: RelationshipCreate) -> Relationship:

POST /api/v1/graph/relationships
"""
return self._post(
data = self._post(
"/api/v1/graph/relationships",
json=rel.model_dump(exclude_none=True),
response_type=Relationship,
json=rel.model_dump(exclude_none=True, by_alias=True),
)
# WHY: API returns {status, message, relationship: {...}} — unwrap
if isinstance(data, dict) and "relationship" in data:
return Relationship.model_validate(data["relationship"])
return Relationship.model_validate(data)

def get(self, relationship_id: str) -> RelationshipDetail:
"""Get relationship details.
Expand Down Expand Up @@ -526,11 +529,13 @@ async def list(
return [Relationship.model_validate(r) for r in items]

async def create(self, rel: RelationshipCreate) -> Relationship:
return await self._post(
data = await self._post(
"/api/v1/graph/relationships",
json=rel.model_dump(exclude_none=True),
response_type=Relationship,
json=rel.model_dump(exclude_none=True, by_alias=True),
)
if isinstance(data, dict) and "relationship" in data:
return Relationship.model_validate(data["relationship"])
return Relationship.model_validate(data)

async def get(self, relationship_id: str) -> RelationshipDetail:
return await self._get(
Expand Down
43 changes: 31 additions & 12 deletions sdks/python/edgequake/types/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from typing import Any

from pydantic import BaseModel, Field
from pydantic import AliasChoices, BaseModel, Field


class GraphNode(BaseModel):
Expand Down Expand Up @@ -174,15 +174,25 @@ class NeighborhoodResponse(BaseModel):


class RelationshipCreate(BaseModel):
"""Request to create a relationship."""
"""Request to create a relationship.

source: str
target: str
relationship_type: str
WHY: the API's CreateRelationshipRequest uses ``src_id``, ``tgt_id``,
``keywords``, ``description`` and ``source_id`` (all required) and
``metadata``. We keep the SDK-friendly names and alias on the wire
(``model_dump(by_alias=True)``).
"""

source: str = Field(serialization_alias="src_id")
target: str = Field(serialization_alias="tgt_id")
relationship_type: str = Field(serialization_alias="keywords")
weight: float | None = None
description: str | None = None
properties: dict[str, Any] | None = None
source_id: str | None = None
description: str = ""
properties: dict[str, Any] | None = Field(
default=None, serialization_alias="metadata"
)
source_id: str = "manual_entry"

model_config = {"populate_by_name": True}


class RelationshipUpdate(BaseModel):
Expand All @@ -195,18 +205,27 @@ class RelationshipUpdate(BaseModel):


class Relationship(BaseModel):
"""A relationship between entities."""
"""A relationship between entities.

WHY: the API returns ``src_id`` / ``tgt_id`` / ``relation_type``; the
short SDK names are accepted too.
"""

id: str | None = None
source: str
target: str
relationship_type: str | None = None
source: str = Field(validation_alias=AliasChoices("source", "src_id"))
target: str = Field(validation_alias=AliasChoices("target", "tgt_id"))
relationship_type: str | None = Field(
default=None,
validation_alias=AliasChoices("relationship_type", "relation_type"),
)
weight: float | None = None
description: str | None = None
properties: dict[str, Any] | None = None
created_at: str | None = None
updated_at: str | None = None

model_config = {"populate_by_name": True}


class RelationshipDetail(Relationship):
"""Detailed relationship info."""
Expand Down
89 changes: 89 additions & 0 deletions sdks/python/tests/test_relationship_create_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""relationships.create must match the server contract.

Server: CreateRelationshipRequest { src_id, tgt_id, keywords, weight,
description, source_id, metadata } with src_id/tgt_id/keywords/description/
source_id required; response is CreateRelationshipResponse
{ status, message, relationship: RelationshipResponse } where the
relationship carries src_id / tgt_id / relation_type.
"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from edgequake import EdgeQuake
from edgequake._client import AsyncEdgeQuake
from edgequake.types.graph import Relationship, RelationshipCreate

_SERVER_RESPONSE = {
"status": "success",
"message": "created",
"relationship": {
"id": "r1",
"src_id": "ALICE",
"tgt_id": "BOB",
"relation_type": "KNOWS",
"keywords": "KNOWS",
"weight": 0.8,
"description": "",
"source_id": "manual_entry",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z",
"metadata": {},
},
}

_EXPECTED_BODY = {
"src_id": "ALICE",
"tgt_id": "BOB",
"keywords": "KNOWS",
"description": "",
"source_id": "manual_entry",
}


def _resp() -> MagicMock:
r = MagicMock()
r.json.return_value = _SERVER_RESPONSE
return r


@patch("edgequake._transport.SyncTransport.request")
def test_create_sends_server_field_names_and_decodes_response(
mock_req: MagicMock,
) -> None:
mock_req.return_value = _resp()
client = EdgeQuake()
result = client.relationships.create(
RelationshipCreate(source="ALICE", target="BOB", relationship_type="KNOWS")
)
assert mock_req.call_args.kwargs["json"] == _EXPECTED_BODY
assert isinstance(result, Relationship)
assert (result.id, result.source, result.target) == ("r1", "ALICE", "BOB")
assert result.relationship_type == "KNOWS"
client.close()


@pytest.mark.asyncio
@patch("edgequake._transport.AsyncTransport.request", new_callable=AsyncMock)
async def test_async_create_sends_server_field_names_and_decodes_response(
mock_req: AsyncMock,
) -> None:
mock_req.return_value = _resp()
client = AsyncEdgeQuake()
result = await client.relationships.create(
RelationshipCreate(source="ALICE", target="BOB", relationship_type="KNOWS")
)
assert mock_req.call_args.kwargs["json"] == _EXPECTED_BODY
assert (result.source, result.target) == ("ALICE", "BOB")


def test_relationship_decodes_server_list_item() -> None:
rel = Relationship.model_validate(_SERVER_RESPONSE["relationship"])
assert (rel.source, rel.target, rel.relationship_type) == (
"ALICE",
"BOB",
"KNOWS",
)
Loading