Skip to content

Commit 4163364

Browse files
committed
feat(client): add get_labels(), get_edge_types(), traverse() — R-SDK3
- LabelInfo, EdgeTypeInfo, PropertyDefinitionInfo, TraverseResult result types - AsyncCoordinodeClient: get_labels(), get_edge_types(), traverse() - CoordinodeClient: sync wrappers for all three methods - traverse() maps direction strings ("outbound"/"inbound"/"both") to TraversalDirection enum - Export new types from coordinode.__init__ - 15 mock-based unit tests in tests/unit/test_schema_crud.py (no Docker) All 33 unit tests pass.
1 parent 20c9ec0 commit 4163364

3 files changed

Lines changed: 325 additions & 0 deletions

File tree

coordinode/coordinode/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
AsyncCoordinodeClient,
2323
CoordinodeClient,
2424
EdgeResult,
25+
EdgeTypeInfo,
26+
LabelInfo,
2527
NodeResult,
28+
PropertyDefinitionInfo,
29+
TraverseResult,
2630
VectorResult,
2731
)
2832

@@ -36,4 +40,8 @@
3640
"NodeResult",
3741
"EdgeResult",
3842
"VectorResult",
43+
"LabelInfo",
44+
"EdgeTypeInfo",
45+
"PropertyDefinitionInfo",
46+
"TraverseResult",
3947
]

coordinode/coordinode/client.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,61 @@ def __repr__(self) -> str:
8585
return f"VectorResult(distance={self.distance:.4f}, node={self.node})"
8686

8787

88+
class PropertyDefinitionInfo:
89+
"""A property definition from the schema (name, type, required, unique)."""
90+
91+
def __init__(self, proto_def: Any) -> None:
92+
self.name: str = proto_def.name
93+
self.type: int = proto_def.type
94+
self.required: bool = proto_def.required
95+
self.unique: bool = proto_def.unique
96+
97+
def __repr__(self) -> str:
98+
return (
99+
f"PropertyDefinition(name={self.name!r}, type={self.type},"
100+
f" required={self.required}, unique={self.unique})"
101+
)
102+
103+
104+
class LabelInfo:
105+
"""A node label returned from the schema registry."""
106+
107+
def __init__(self, proto_label: Any) -> None:
108+
self.name: str = proto_label.name
109+
self.version: int = proto_label.version
110+
self.properties: list[PropertyDefinitionInfo] = [
111+
PropertyDefinitionInfo(p) for p in proto_label.properties
112+
]
113+
114+
def __repr__(self) -> str:
115+
return f"LabelInfo(name={self.name!r}, properties={self.properties})"
116+
117+
118+
class EdgeTypeInfo:
119+
"""An edge type returned from the schema registry."""
120+
121+
def __init__(self, proto_edge_type: Any) -> None:
122+
self.name: str = proto_edge_type.name
123+
self.version: int = proto_edge_type.version
124+
self.properties: list[PropertyDefinitionInfo] = [
125+
PropertyDefinitionInfo(p) for p in proto_edge_type.properties
126+
]
127+
128+
def __repr__(self) -> str:
129+
return f"EdgeTypeInfo(name={self.name!r}, properties={self.properties})"
130+
131+
132+
class TraverseResult:
133+
"""Result of a graph traversal: reached nodes and traversed edges."""
134+
135+
def __init__(self, proto_response: Any) -> None:
136+
self.nodes: list[NodeResult] = [NodeResult(n) for n in proto_response.nodes]
137+
self.edges: list[EdgeResult] = [EdgeResult(e) for e in proto_response.edges]
138+
139+
def __repr__(self) -> str:
140+
return f"TraverseResult(nodes={len(self.nodes)}, edges={len(self.edges)})"
141+
142+
88143
# ── Async client ─────────────────────────────────────────────────────────────
89144

90145

@@ -303,6 +358,61 @@ async def get_schema_text(self) -> str:
303358

304359
return "\n".join(lines)
305360

361+
async def get_labels(self) -> list[LabelInfo]:
362+
"""Return all node labels defined in the schema."""
363+
from coordinode._proto.coordinode.v1.graph.schema_pb2 import ListLabelsRequest # type: ignore[import]
364+
365+
resp = await self._schema_stub.ListLabels(ListLabelsRequest(), timeout=self._timeout)
366+
return [LabelInfo(label) for label in resp.labels]
367+
368+
async def get_edge_types(self) -> list[EdgeTypeInfo]:
369+
"""Return all edge types defined in the schema."""
370+
from coordinode._proto.coordinode.v1.graph.schema_pb2 import ListEdgeTypesRequest # type: ignore[import]
371+
372+
resp = await self._schema_stub.ListEdgeTypes(ListEdgeTypesRequest(), timeout=self._timeout)
373+
return [EdgeTypeInfo(et) for et in resp.edge_types]
374+
375+
async def traverse(
376+
self,
377+
start_node_id: int,
378+
edge_type: str,
379+
direction: str = "outbound",
380+
max_depth: int = 1,
381+
) -> TraverseResult:
382+
"""Traverse the graph from *start_node_id* following *edge_type* edges.
383+
384+
Args:
385+
start_node_id: ID of the node to start from.
386+
edge_type: Edge type label to follow (e.g. ``"KNOWS"``).
387+
direction: ``"outbound"`` (default), ``"inbound"``, or ``"both"``.
388+
max_depth: Maximum hop count (default 1).
389+
390+
Returns:
391+
:class:`TraverseResult` with ``nodes`` and ``edges`` lists.
392+
"""
393+
from coordinode._proto.coordinode.v1.graph.graph_pb2 import ( # type: ignore[import]
394+
TraversalDirection,
395+
TraverseRequest,
396+
)
397+
398+
_direction_map = {
399+
"outbound": TraversalDirection.TRAVERSAL_DIRECTION_OUTBOUND,
400+
"inbound": TraversalDirection.TRAVERSAL_DIRECTION_INBOUND,
401+
"both": TraversalDirection.TRAVERSAL_DIRECTION_BOTH,
402+
}
403+
direction_value = _direction_map.get(
404+
direction.lower(), TraversalDirection.TRAVERSAL_DIRECTION_OUTBOUND
405+
)
406+
407+
req = TraverseRequest(
408+
start_node_id=start_node_id,
409+
edge_type=edge_type,
410+
direction=direction_value,
411+
max_depth=max_depth,
412+
)
413+
resp = await self._graph_stub.Traverse(req, timeout=self._timeout)
414+
return TraverseResult(resp)
415+
306416
async def health(self) -> bool:
307417
from coordinode._proto.coordinode.v1.health.health_pb2 import ( # type: ignore[import]
308418
HealthCheckRequest,
@@ -422,6 +532,24 @@ def create_edge(
422532
def get_schema_text(self) -> str:
423533
return self._run(self._async.get_schema_text())
424534

535+
def get_labels(self) -> list[LabelInfo]:
536+
"""Return all node labels defined in the schema."""
537+
return self._run(self._async.get_labels())
538+
539+
def get_edge_types(self) -> list[EdgeTypeInfo]:
540+
"""Return all edge types defined in the schema."""
541+
return self._run(self._async.get_edge_types())
542+
543+
def traverse(
544+
self,
545+
start_node_id: int,
546+
edge_type: str,
547+
direction: str = "outbound",
548+
max_depth: int = 1,
549+
) -> TraverseResult:
550+
"""Traverse the graph from *start_node_id* following *edge_type* edges."""
551+
return self._run(self._async.traverse(start_node_id, edge_type, direction, max_depth))
552+
425553
def health(self) -> bool:
426554
return self._run(self._async.health())
427555

tests/unit/test_schema_crud.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Unit tests for R-SDK3 additions: LabelInfo, EdgeTypeInfo, TraverseResult.
2+
3+
All tests are mock-based — no proto stubs or running server required.
4+
Pattern mirrors test_types.py: fake proto objects with the same attribute
5+
interface that real generated messages provide.
6+
"""
7+
8+
from coordinode.client import (
9+
EdgeResult,
10+
EdgeTypeInfo,
11+
LabelInfo,
12+
NodeResult,
13+
PropertyDefinitionInfo,
14+
TraverseResult,
15+
)
16+
17+
18+
# ── Fake proto stubs ─────────────────────────────────────────────────────────
19+
20+
21+
class _FakePropDef:
22+
"""Matches proto PropertyDefinition shape."""
23+
24+
def __init__(self, name: str, type_: int, required: bool = False, unique: bool = False) -> None:
25+
self.name = name
26+
self.type = type_
27+
self.required = required
28+
self.unique = unique
29+
30+
31+
class _FakeLabel:
32+
"""Matches proto Label shape."""
33+
34+
def __init__(self, name: str, version: int = 1, properties=None) -> None:
35+
self.name = name
36+
self.version = version
37+
self.properties = properties or []
38+
39+
40+
class _FakeEdgeType:
41+
"""Matches proto EdgeType shape."""
42+
43+
def __init__(self, name: str, version: int = 1, properties=None) -> None:
44+
self.name = name
45+
self.version = version
46+
self.properties = properties or []
47+
48+
49+
class _FakeNode:
50+
"""Matches proto Node shape."""
51+
52+
def __init__(self, node_id: int, labels=None, properties=None) -> None:
53+
self.node_id = node_id
54+
self.labels = labels or []
55+
self.properties = properties or {}
56+
57+
58+
class _FakeEdge:
59+
"""Matches proto Edge shape."""
60+
61+
def __init__(self, edge_id: int, edge_type: str, source: int, target: int, properties=None) -> None:
62+
self.edge_id = edge_id
63+
self.edge_type = edge_type
64+
self.source_node_id = source
65+
self.target_node_id = target
66+
self.properties = properties or {}
67+
68+
69+
class _FakeTraverseResponse:
70+
"""Matches proto TraverseResponse shape."""
71+
72+
def __init__(self, nodes=None, edges=None) -> None:
73+
self.nodes = nodes or []
74+
self.edges = edges or []
75+
76+
77+
# ── PropertyDefinitionInfo ───────────────────────────────────────────────────
78+
79+
80+
class TestPropertyDefinitionInfo:
81+
def test_fields_are_mapped(self):
82+
# type=3 = PROPERTY_TYPE_STRING (int value from proto enum)
83+
p = PropertyDefinitionInfo(_FakePropDef("name", 3, required=True, unique=False))
84+
assert p.name == "name"
85+
assert p.type == 3
86+
assert p.required is True
87+
assert p.unique is False
88+
89+
def test_repr_contains_name(self):
90+
p = PropertyDefinitionInfo(_FakePropDef("age", 1))
91+
assert "age" in repr(p)
92+
93+
def test_optional_flags_default_false(self):
94+
p = PropertyDefinitionInfo(_FakePropDef("x", 2))
95+
assert p.required is False
96+
assert p.unique is False
97+
98+
99+
# ── LabelInfo ────────────────────────────────────────────────────────────────
100+
101+
102+
class TestLabelInfo:
103+
def test_empty_properties(self):
104+
label = LabelInfo(_FakeLabel("Person", version=2))
105+
assert label.name == "Person"
106+
assert label.version == 2
107+
assert label.properties == []
108+
109+
def test_properties_are_wrapped(self):
110+
props = [_FakePropDef("name", 3), _FakePropDef("age", 1)]
111+
label = LabelInfo(_FakeLabel("User", properties=props))
112+
assert len(label.properties) == 2
113+
assert all(isinstance(p, PropertyDefinitionInfo) for p in label.properties)
114+
assert label.properties[0].name == "name"
115+
assert label.properties[1].name == "age"
116+
117+
def test_repr_contains_name(self):
118+
label = LabelInfo(_FakeLabel("Movie"))
119+
assert "Movie" in repr(label)
120+
121+
def test_version_zero(self):
122+
# Schema registry may return version=0 for newly created labels.
123+
label = LabelInfo(_FakeLabel("Draft", version=0))
124+
assert label.version == 0
125+
126+
127+
# ── EdgeTypeInfo ─────────────────────────────────────────────────────────────
128+
129+
130+
class TestEdgeTypeInfo:
131+
def test_basic_fields(self):
132+
et = EdgeTypeInfo(_FakeEdgeType("KNOWS", version=1))
133+
assert et.name == "KNOWS"
134+
assert et.version == 1
135+
assert et.properties == []
136+
137+
def test_properties_are_wrapped(self):
138+
props = [_FakePropDef("since", 6)] # 6 = TIMESTAMP
139+
et = EdgeTypeInfo(_FakeEdgeType("FOLLOWS", properties=props))
140+
assert len(et.properties) == 1
141+
assert et.properties[0].name == "since"
142+
143+
def test_repr_contains_name(self):
144+
et = EdgeTypeInfo(_FakeEdgeType("RATED"))
145+
assert "RATED" in repr(et)
146+
147+
148+
# ── TraverseResult ───────────────────────────────────────────────────────────
149+
150+
151+
class TestTraverseResult:
152+
def test_empty_response(self):
153+
result = TraverseResult(_FakeTraverseResponse())
154+
assert result.nodes == []
155+
assert result.edges == []
156+
157+
def test_nodes_are_wrapped_as_node_results(self):
158+
nodes = [_FakeNode(1, ["Person"]), _FakeNode(2, ["Movie"])]
159+
result = TraverseResult(_FakeTraverseResponse(nodes=nodes))
160+
assert len(result.nodes) == 2
161+
assert all(isinstance(n, NodeResult) for n in result.nodes)
162+
assert result.nodes[0].id == 1
163+
assert result.nodes[1].id == 2
164+
165+
def test_edges_are_wrapped_as_edge_results(self):
166+
edges = [_FakeEdge(10, "KNOWS", source=1, target=2)]
167+
result = TraverseResult(_FakeTraverseResponse(edges=edges))
168+
assert len(result.edges) == 1
169+
assert isinstance(result.edges[0], EdgeResult)
170+
assert result.edges[0].source_id == 1
171+
assert result.edges[0].target_id == 2
172+
assert result.edges[0].type == "KNOWS"
173+
174+
def test_mixed_nodes_and_edges(self):
175+
nodes = [_FakeNode(1, ["A"]), _FakeNode(2, ["B"]), _FakeNode(3, ["C"])]
176+
edges = [
177+
_FakeEdge(10, "REL", 1, 2),
178+
_FakeEdge(11, "REL", 2, 3),
179+
]
180+
result = TraverseResult(_FakeTraverseResponse(nodes=nodes, edges=edges))
181+
assert len(result.nodes) == 3
182+
assert len(result.edges) == 2
183+
184+
def test_repr_shows_counts(self):
185+
nodes = [_FakeNode(1, [])]
186+
result = TraverseResult(_FakeTraverseResponse(nodes=nodes))
187+
r = repr(result)
188+
assert "1" in r # 1 node
189+
assert "0" in r # 0 edges

0 commit comments

Comments
 (0)