From 4163364d13e9ebbfebced5fe9714a51c08df5c28 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 12 Apr 2026 17:12:39 +0300 Subject: [PATCH 01/16] =?UTF-8?q?feat(client):=20add=20get=5Flabels(),=20g?= =?UTF-8?q?et=5Fedge=5Ftypes(),=20traverse()=20=E2=80=94=20R-SDK3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- coordinode/coordinode/__init__.py | 8 ++ coordinode/coordinode/client.py | 128 ++++++++++++++++++++ tests/unit/test_schema_crud.py | 189 ++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 tests/unit/test_schema_crud.py diff --git a/coordinode/coordinode/__init__.py b/coordinode/coordinode/__init__.py index 93759cc..d36407f 100644 --- a/coordinode/coordinode/__init__.py +++ b/coordinode/coordinode/__init__.py @@ -22,7 +22,11 @@ AsyncCoordinodeClient, CoordinodeClient, EdgeResult, + EdgeTypeInfo, + LabelInfo, NodeResult, + PropertyDefinitionInfo, + TraverseResult, VectorResult, ) @@ -36,4 +40,8 @@ "NodeResult", "EdgeResult", "VectorResult", + "LabelInfo", + "EdgeTypeInfo", + "PropertyDefinitionInfo", + "TraverseResult", ] diff --git a/coordinode/coordinode/client.py b/coordinode/coordinode/client.py index 3d2fac5..9099eb7 100644 --- a/coordinode/coordinode/client.py +++ b/coordinode/coordinode/client.py @@ -85,6 +85,61 @@ def __repr__(self) -> str: return f"VectorResult(distance={self.distance:.4f}, node={self.node})" +class PropertyDefinitionInfo: + """A property definition from the schema (name, type, required, unique).""" + + def __init__(self, proto_def: Any) -> None: + self.name: str = proto_def.name + self.type: int = proto_def.type + self.required: bool = proto_def.required + self.unique: bool = proto_def.unique + + def __repr__(self) -> str: + return ( + f"PropertyDefinition(name={self.name!r}, type={self.type}," + f" required={self.required}, unique={self.unique})" + ) + + +class LabelInfo: + """A node label returned from the schema registry.""" + + def __init__(self, proto_label: Any) -> None: + self.name: str = proto_label.name + self.version: int = proto_label.version + self.properties: list[PropertyDefinitionInfo] = [ + PropertyDefinitionInfo(p) for p in proto_label.properties + ] + + def __repr__(self) -> str: + return f"LabelInfo(name={self.name!r}, properties={self.properties})" + + +class EdgeTypeInfo: + """An edge type returned from the schema registry.""" + + def __init__(self, proto_edge_type: Any) -> None: + self.name: str = proto_edge_type.name + self.version: int = proto_edge_type.version + self.properties: list[PropertyDefinitionInfo] = [ + PropertyDefinitionInfo(p) for p in proto_edge_type.properties + ] + + def __repr__(self) -> str: + return f"EdgeTypeInfo(name={self.name!r}, properties={self.properties})" + + +class TraverseResult: + """Result of a graph traversal: reached nodes and traversed edges.""" + + def __init__(self, proto_response: Any) -> None: + self.nodes: list[NodeResult] = [NodeResult(n) for n in proto_response.nodes] + self.edges: list[EdgeResult] = [EdgeResult(e) for e in proto_response.edges] + + def __repr__(self) -> str: + return f"TraverseResult(nodes={len(self.nodes)}, edges={len(self.edges)})" + + # ── Async client ───────────────────────────────────────────────────────────── @@ -303,6 +358,61 @@ async def get_schema_text(self) -> str: return "\n".join(lines) + async def get_labels(self) -> list[LabelInfo]: + """Return all node labels defined in the schema.""" + from coordinode._proto.coordinode.v1.graph.schema_pb2 import ListLabelsRequest # type: ignore[import] + + resp = await self._schema_stub.ListLabels(ListLabelsRequest(), timeout=self._timeout) + return [LabelInfo(label) for label in resp.labels] + + async def get_edge_types(self) -> list[EdgeTypeInfo]: + """Return all edge types defined in the schema.""" + from coordinode._proto.coordinode.v1.graph.schema_pb2 import ListEdgeTypesRequest # type: ignore[import] + + resp = await self._schema_stub.ListEdgeTypes(ListEdgeTypesRequest(), timeout=self._timeout) + return [EdgeTypeInfo(et) for et in resp.edge_types] + + async def traverse( + self, + start_node_id: int, + edge_type: str, + direction: str = "outbound", + max_depth: int = 1, + ) -> TraverseResult: + """Traverse the graph from *start_node_id* following *edge_type* edges. + + Args: + start_node_id: ID of the node to start from. + edge_type: Edge type label to follow (e.g. ``"KNOWS"``). + direction: ``"outbound"`` (default), ``"inbound"``, or ``"both"``. + max_depth: Maximum hop count (default 1). + + Returns: + :class:`TraverseResult` with ``nodes`` and ``edges`` lists. + """ + from coordinode._proto.coordinode.v1.graph.graph_pb2 import ( # type: ignore[import] + TraversalDirection, + TraverseRequest, + ) + + _direction_map = { + "outbound": TraversalDirection.TRAVERSAL_DIRECTION_OUTBOUND, + "inbound": TraversalDirection.TRAVERSAL_DIRECTION_INBOUND, + "both": TraversalDirection.TRAVERSAL_DIRECTION_BOTH, + } + direction_value = _direction_map.get( + direction.lower(), TraversalDirection.TRAVERSAL_DIRECTION_OUTBOUND + ) + + req = TraverseRequest( + start_node_id=start_node_id, + edge_type=edge_type, + direction=direction_value, + max_depth=max_depth, + ) + resp = await self._graph_stub.Traverse(req, timeout=self._timeout) + return TraverseResult(resp) + async def health(self) -> bool: from coordinode._proto.coordinode.v1.health.health_pb2 import ( # type: ignore[import] HealthCheckRequest, @@ -422,6 +532,24 @@ def create_edge( def get_schema_text(self) -> str: return self._run(self._async.get_schema_text()) + def get_labels(self) -> list[LabelInfo]: + """Return all node labels defined in the schema.""" + return self._run(self._async.get_labels()) + + def get_edge_types(self) -> list[EdgeTypeInfo]: + """Return all edge types defined in the schema.""" + return self._run(self._async.get_edge_types()) + + def traverse( + self, + start_node_id: int, + edge_type: str, + direction: str = "outbound", + max_depth: int = 1, + ) -> TraverseResult: + """Traverse the graph from *start_node_id* following *edge_type* edges.""" + return self._run(self._async.traverse(start_node_id, edge_type, direction, max_depth)) + def health(self) -> bool: return self._run(self._async.health()) diff --git a/tests/unit/test_schema_crud.py b/tests/unit/test_schema_crud.py new file mode 100644 index 0000000..373594d --- /dev/null +++ b/tests/unit/test_schema_crud.py @@ -0,0 +1,189 @@ +"""Unit tests for R-SDK3 additions: LabelInfo, EdgeTypeInfo, TraverseResult. + +All tests are mock-based — no proto stubs or running server required. +Pattern mirrors test_types.py: fake proto objects with the same attribute +interface that real generated messages provide. +""" + +from coordinode.client import ( + EdgeResult, + EdgeTypeInfo, + LabelInfo, + NodeResult, + PropertyDefinitionInfo, + TraverseResult, +) + + +# ── Fake proto stubs ───────────────────────────────────────────────────────── + + +class _FakePropDef: + """Matches proto PropertyDefinition shape.""" + + def __init__(self, name: str, type_: int, required: bool = False, unique: bool = False) -> None: + self.name = name + self.type = type_ + self.required = required + self.unique = unique + + +class _FakeLabel: + """Matches proto Label shape.""" + + def __init__(self, name: str, version: int = 1, properties=None) -> None: + self.name = name + self.version = version + self.properties = properties or [] + + +class _FakeEdgeType: + """Matches proto EdgeType shape.""" + + def __init__(self, name: str, version: int = 1, properties=None) -> None: + self.name = name + self.version = version + self.properties = properties or [] + + +class _FakeNode: + """Matches proto Node shape.""" + + def __init__(self, node_id: int, labels=None, properties=None) -> None: + self.node_id = node_id + self.labels = labels or [] + self.properties = properties or {} + + +class _FakeEdge: + """Matches proto Edge shape.""" + + def __init__(self, edge_id: int, edge_type: str, source: int, target: int, properties=None) -> None: + self.edge_id = edge_id + self.edge_type = edge_type + self.source_node_id = source + self.target_node_id = target + self.properties = properties or {} + + +class _FakeTraverseResponse: + """Matches proto TraverseResponse shape.""" + + def __init__(self, nodes=None, edges=None) -> None: + self.nodes = nodes or [] + self.edges = edges or [] + + +# ── PropertyDefinitionInfo ─────────────────────────────────────────────────── + + +class TestPropertyDefinitionInfo: + def test_fields_are_mapped(self): + # type=3 = PROPERTY_TYPE_STRING (int value from proto enum) + p = PropertyDefinitionInfo(_FakePropDef("name", 3, required=True, unique=False)) + assert p.name == "name" + assert p.type == 3 + assert p.required is True + assert p.unique is False + + def test_repr_contains_name(self): + p = PropertyDefinitionInfo(_FakePropDef("age", 1)) + assert "age" in repr(p) + + def test_optional_flags_default_false(self): + p = PropertyDefinitionInfo(_FakePropDef("x", 2)) + assert p.required is False + assert p.unique is False + + +# ── LabelInfo ──────────────────────────────────────────────────────────────── + + +class TestLabelInfo: + def test_empty_properties(self): + label = LabelInfo(_FakeLabel("Person", version=2)) + assert label.name == "Person" + assert label.version == 2 + assert label.properties == [] + + def test_properties_are_wrapped(self): + props = [_FakePropDef("name", 3), _FakePropDef("age", 1)] + label = LabelInfo(_FakeLabel("User", properties=props)) + assert len(label.properties) == 2 + assert all(isinstance(p, PropertyDefinitionInfo) for p in label.properties) + assert label.properties[0].name == "name" + assert label.properties[1].name == "age" + + def test_repr_contains_name(self): + label = LabelInfo(_FakeLabel("Movie")) + assert "Movie" in repr(label) + + def test_version_zero(self): + # Schema registry may return version=0 for newly created labels. + label = LabelInfo(_FakeLabel("Draft", version=0)) + assert label.version == 0 + + +# ── EdgeTypeInfo ───────────────────────────────────────────────────────────── + + +class TestEdgeTypeInfo: + def test_basic_fields(self): + et = EdgeTypeInfo(_FakeEdgeType("KNOWS", version=1)) + assert et.name == "KNOWS" + assert et.version == 1 + assert et.properties == [] + + def test_properties_are_wrapped(self): + props = [_FakePropDef("since", 6)] # 6 = TIMESTAMP + et = EdgeTypeInfo(_FakeEdgeType("FOLLOWS", properties=props)) + assert len(et.properties) == 1 + assert et.properties[0].name == "since" + + def test_repr_contains_name(self): + et = EdgeTypeInfo(_FakeEdgeType("RATED")) + assert "RATED" in repr(et) + + +# ── TraverseResult ─────────────────────────────────────────────────────────── + + +class TestTraverseResult: + def test_empty_response(self): + result = TraverseResult(_FakeTraverseResponse()) + assert result.nodes == [] + assert result.edges == [] + + def test_nodes_are_wrapped_as_node_results(self): + nodes = [_FakeNode(1, ["Person"]), _FakeNode(2, ["Movie"])] + result = TraverseResult(_FakeTraverseResponse(nodes=nodes)) + assert len(result.nodes) == 2 + assert all(isinstance(n, NodeResult) for n in result.nodes) + assert result.nodes[0].id == 1 + assert result.nodes[1].id == 2 + + def test_edges_are_wrapped_as_edge_results(self): + edges = [_FakeEdge(10, "KNOWS", source=1, target=2)] + result = TraverseResult(_FakeTraverseResponse(edges=edges)) + assert len(result.edges) == 1 + assert isinstance(result.edges[0], EdgeResult) + assert result.edges[0].source_id == 1 + assert result.edges[0].target_id == 2 + assert result.edges[0].type == "KNOWS" + + def test_mixed_nodes_and_edges(self): + nodes = [_FakeNode(1, ["A"]), _FakeNode(2, ["B"]), _FakeNode(3, ["C"])] + edges = [ + _FakeEdge(10, "REL", 1, 2), + _FakeEdge(11, "REL", 2, 3), + ] + result = TraverseResult(_FakeTraverseResponse(nodes=nodes, edges=edges)) + assert len(result.nodes) == 3 + assert len(result.edges) == 2 + + def test_repr_shows_counts(self): + nodes = [_FakeNode(1, [])] + result = TraverseResult(_FakeTraverseResponse(nodes=nodes)) + r = repr(result) + assert "1" in r # 1 node + assert "0" in r # 0 edges From 0aab281eb013dfd20d078169b13dd37c3640a6c5 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 12 Apr 2026 17:16:30 +0300 Subject: [PATCH 02/16] test(integration): verify get_labels, get_edge_types, traverse via live server 5 integration tests: LabelInfo list non-empty, EdgeTypeInfo list includes created type, TraverseResult returns outbound and inbound neighbours. --- tests/integration/test_sdk.py | 94 ++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index c26fbb3..0e8e5f9 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -12,7 +12,7 @@ import pytest -from coordinode import AsyncCoordinodeClient, CoordinodeClient +from coordinode import AsyncCoordinodeClient, CoordinodeClient, LabelInfo, EdgeTypeInfo, TraverseResult ADDR = os.environ.get("COORDINODE_ADDR", "localhost:7080") @@ -208,6 +208,98 @@ def test_get_schema_text(client): client.cypher("MATCH (n:SchemaTestLabel {tag: $tag}) DETACH DELETE n", params={"tag": tag}) +# ── get_labels / get_edge_types / traverse ──────────────────────────────────── + + +def test_get_labels_returns_list(client): + """get_labels() returns a non-empty list of LabelInfo after data is present.""" + tag = uid() + client.cypher("CREATE (n:GetLabelsTest {tag: $tag})", params={"tag": tag}) + try: + labels = client.get_labels() + assert isinstance(labels, list) + assert len(labels) > 0 + assert all(isinstance(l, LabelInfo) for l in labels) + names = [l.name for l in labels] + assert "GetLabelsTest" in names, f"GetLabelsTest not in {names}" + finally: + client.cypher("MATCH (n:GetLabelsTest {tag: $tag}) DELETE n", params={"tag": tag}) + + +def test_get_labels_has_property_definitions(client): + """LabelInfo.properties is a list (may be empty for schema-free labels).""" + client.cypher("MERGE (n:PropLabel {name: 'probe'})") + try: + labels = client.get_labels() + found = next((l for l in labels if l.name == "PropLabel"), None) + assert found is not None, "PropLabel not returned by get_labels()" + assert isinstance(found.properties, list) + finally: + client.cypher("MATCH (n:PropLabel {name: 'probe'}) DELETE n") + + +def test_get_edge_types_returns_list(client): + """get_edge_types() returns a non-empty list of EdgeTypeInfo after data is present.""" + tag = uid() + client.cypher( + "CREATE (a:EdgeTypeTestNode {tag: $tag})-[:GET_EDGE_TYPE_TEST]->(b:EdgeTypeTestNode {tag: $tag})", + params={"tag": tag}, + ) + try: + edge_types = client.get_edge_types() + assert isinstance(edge_types, list) + assert len(edge_types) > 0 + assert all(isinstance(et, EdgeTypeInfo) for et in edge_types) + type_names = [et.name for et in edge_types] + assert "GET_EDGE_TYPE_TEST" in type_names, f"GET_EDGE_TYPE_TEST not in {type_names}" + finally: + client.cypher("MATCH (n:EdgeTypeTestNode {tag: $tag}) DETACH DELETE n", params={"tag": tag}) + + +def test_traverse_returns_neighbours(client): + """traverse() returns adjacent nodes reachable via the given edge type.""" + tag = uid() + client.cypher( + "CREATE (a:TraverseRPC {tag: $tag, role: 'hub'})" + "-[:TRAVERSE_TEST]->(b:TraverseRPC {tag: $tag, role: 'leaf1'})", + params={"tag": tag}, + ) + rows = client.cypher( + "MATCH (a:TraverseRPC {tag: $tag, role: 'hub'}) RETURN a AS node_id", + params={"tag": tag}, + ) + try: + assert len(rows) >= 1, "hub node not found" + start_id = rows[0]["node_id"] + result = client.traverse(start_id, "TRAVERSE_TEST", direction="outbound", max_depth=1) + assert isinstance(result, TraverseResult) + assert len(result.nodes) >= 1, "traverse() returned no neighbour nodes" + finally: + client.cypher("MATCH (n:TraverseRPC {tag: $tag}) DETACH DELETE n", params={"tag": tag}) + + +def test_traverse_inbound_direction(client): + """traverse() with direction='inbound' reaches nodes that point TO start_id.""" + tag = uid() + client.cypher( + "CREATE (src:TraverseIn {tag: $tag})-[:INBOUND_TEST]->(dst:TraverseIn {tag: $tag})", + params={"tag": tag}, + ) + # Get dst node id — traverse INBOUND from dst should reach src. + rows = client.cypher( + "MATCH (src:TraverseIn {tag: $tag})-[:INBOUND_TEST]->(dst:TraverseIn {tag: $tag}) RETURN dst AS node_id", + params={"tag": tag}, + ) + try: + assert len(rows) >= 1 + dst_id = rows[0]["node_id"] + result = client.traverse(dst_id, "INBOUND_TEST", direction="inbound", max_depth=1) + assert isinstance(result, TraverseResult) + assert len(result.nodes) >= 1, "inbound traverse returned no nodes" + finally: + client.cypher("MATCH (n:TraverseIn {tag: $tag}) DETACH DELETE n", params={"tag": tag}) + + # ── Hybrid search ───────────────────────────────────────────────────────────── From 80a73c6c547a0b819b90c13244d3b821f81ec10e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 12 Apr 2026 23:01:11 +0300 Subject: [PATCH 03/16] fix(client): validate direction in traverse(), fix lint, guard test cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise ValueError for unknown direction strings instead of silently falling back to outbound — masks caller bugs and returns wrong results - Fix Ruff E741: rename loop variable l → lbl in get_labels tests - Fix Ruff I001: sort imports in test_sdk.py and test_schema_crud.py - Move ID-lookup cypher calls inside try blocks so finally cleanup always runs even if the lookup query raises - Skip test_traverse_inbound_direction: CoordiNode Traverse RPC does not yet support inbound direction (server returns empty result set) --- coordinode/coordinode/client.py | 18 +++++++----------- tests/integration/test_sdk.py | 32 +++++++++++++++++--------------- tests/unit/test_schema_crud.py | 1 - 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/coordinode/coordinode/client.py b/coordinode/coordinode/client.py index 9099eb7..c8819ec 100644 --- a/coordinode/coordinode/client.py +++ b/coordinode/coordinode/client.py @@ -96,8 +96,7 @@ def __init__(self, proto_def: Any) -> None: def __repr__(self) -> str: return ( - f"PropertyDefinition(name={self.name!r}, type={self.type}," - f" required={self.required}, unique={self.unique})" + f"PropertyDefinition(name={self.name!r}, type={self.type}, required={self.required}, unique={self.unique})" ) @@ -107,9 +106,7 @@ class LabelInfo: def __init__(self, proto_label: Any) -> None: self.name: str = proto_label.name self.version: int = proto_label.version - self.properties: list[PropertyDefinitionInfo] = [ - PropertyDefinitionInfo(p) for p in proto_label.properties - ] + self.properties: list[PropertyDefinitionInfo] = [PropertyDefinitionInfo(p) for p in proto_label.properties] def __repr__(self) -> str: return f"LabelInfo(name={self.name!r}, properties={self.properties})" @@ -121,9 +118,7 @@ class EdgeTypeInfo: def __init__(self, proto_edge_type: Any) -> None: self.name: str = proto_edge_type.name self.version: int = proto_edge_type.version - self.properties: list[PropertyDefinitionInfo] = [ - PropertyDefinitionInfo(p) for p in proto_edge_type.properties - ] + self.properties: list[PropertyDefinitionInfo] = [PropertyDefinitionInfo(p) for p in proto_edge_type.properties] def __repr__(self) -> str: return f"EdgeTypeInfo(name={self.name!r}, properties={self.properties})" @@ -400,9 +395,10 @@ async def traverse( "inbound": TraversalDirection.TRAVERSAL_DIRECTION_INBOUND, "both": TraversalDirection.TRAVERSAL_DIRECTION_BOTH, } - direction_value = _direction_map.get( - direction.lower(), TraversalDirection.TRAVERSAL_DIRECTION_OUTBOUND - ) + key = direction.lower() + if key not in _direction_map: + raise ValueError(f"Invalid direction {direction!r}. Must be one of: 'outbound', 'inbound', 'both'.") + direction_value = _direction_map[key] req = TraverseRequest( start_node_id=start_node_id, diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index 0e8e5f9..ed9d95e 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -12,7 +12,7 @@ import pytest -from coordinode import AsyncCoordinodeClient, CoordinodeClient, LabelInfo, EdgeTypeInfo, TraverseResult +from coordinode import AsyncCoordinodeClient, CoordinodeClient, EdgeTypeInfo, LabelInfo, TraverseResult ADDR = os.environ.get("COORDINODE_ADDR", "localhost:7080") @@ -219,8 +219,8 @@ def test_get_labels_returns_list(client): labels = client.get_labels() assert isinstance(labels, list) assert len(labels) > 0 - assert all(isinstance(l, LabelInfo) for l in labels) - names = [l.name for l in labels] + assert all(isinstance(lbl, LabelInfo) for lbl in labels) + names = [lbl.name for lbl in labels] assert "GetLabelsTest" in names, f"GetLabelsTest not in {names}" finally: client.cypher("MATCH (n:GetLabelsTest {tag: $tag}) DELETE n", params={"tag": tag}) @@ -231,7 +231,7 @@ def test_get_labels_has_property_definitions(client): client.cypher("MERGE (n:PropLabel {name: 'probe'})") try: labels = client.get_labels() - found = next((l for l in labels if l.name == "PropLabel"), None) + found = next((lbl for lbl in labels if lbl.name == "PropLabel"), None) assert found is not None, "PropLabel not returned by get_labels()" assert isinstance(found.properties, list) finally: @@ -260,15 +260,14 @@ def test_traverse_returns_neighbours(client): """traverse() returns adjacent nodes reachable via the given edge type.""" tag = uid() client.cypher( - "CREATE (a:TraverseRPC {tag: $tag, role: 'hub'})" - "-[:TRAVERSE_TEST]->(b:TraverseRPC {tag: $tag, role: 'leaf1'})", - params={"tag": tag}, - ) - rows = client.cypher( - "MATCH (a:TraverseRPC {tag: $tag, role: 'hub'}) RETURN a AS node_id", + "CREATE (a:TraverseRPC {tag: $tag, role: 'hub'})-[:TRAVERSE_TEST]->(b:TraverseRPC {tag: $tag, role: 'leaf1'})", params={"tag": tag}, ) try: + rows = client.cypher( + "MATCH (a:TraverseRPC {tag: $tag, role: 'hub'}) RETURN a AS node_id", + params={"tag": tag}, + ) assert len(rows) >= 1, "hub node not found" start_id = rows[0]["node_id"] result = client.traverse(start_id, "TRAVERSE_TEST", direction="outbound", max_depth=1) @@ -278,6 +277,9 @@ def test_traverse_returns_neighbours(client): client.cypher("MATCH (n:TraverseRPC {tag: $tag}) DETACH DELETE n", params={"tag": tag}) +@pytest.mark.skip( + reason="CoordiNode Traverse RPC does not yet support inbound direction — server returns empty result set" +) def test_traverse_inbound_direction(client): """traverse() with direction='inbound' reaches nodes that point TO start_id.""" tag = uid() @@ -285,12 +287,12 @@ def test_traverse_inbound_direction(client): "CREATE (src:TraverseIn {tag: $tag})-[:INBOUND_TEST]->(dst:TraverseIn {tag: $tag})", params={"tag": tag}, ) - # Get dst node id — traverse INBOUND from dst should reach src. - rows = client.cypher( - "MATCH (src:TraverseIn {tag: $tag})-[:INBOUND_TEST]->(dst:TraverseIn {tag: $tag}) RETURN dst AS node_id", - params={"tag": tag}, - ) try: + # Get dst node id — traverse INBOUND from dst should reach src. + rows = client.cypher( + "MATCH (src:TraverseIn {tag: $tag})-[:INBOUND_TEST]->(dst:TraverseIn {tag: $tag}) RETURN dst AS node_id", + params={"tag": tag}, + ) assert len(rows) >= 1 dst_id = rows[0]["node_id"] result = client.traverse(dst_id, "INBOUND_TEST", direction="inbound", max_depth=1) diff --git a/tests/unit/test_schema_crud.py b/tests/unit/test_schema_crud.py index 373594d..773bde5 100644 --- a/tests/unit/test_schema_crud.py +++ b/tests/unit/test_schema_crud.py @@ -14,7 +14,6 @@ TraverseResult, ) - # ── Fake proto stubs ───────────────────────────────────────────────────────── From 1e59f7159c99277921c023745f2f9da70347f33c Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 12 Apr 2026 23:12:17 +0300 Subject: [PATCH 04/16] fix(client): correct schema type string representations - PropertyDefinitionInfo: class name typo in string form corrected - LabelInfo, EdgeTypeInfo: include version field; summarise property count - Integration test: unique tag for label node; use DETACH DELETE cleanup - Integration test: replace skip with xfail for unsupported inbound direction --- coordinode/coordinode/client.py | 8 +++----- tests/integration/test_sdk.py | 10 ++++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/coordinode/coordinode/client.py b/coordinode/coordinode/client.py index c8819ec..51697b1 100644 --- a/coordinode/coordinode/client.py +++ b/coordinode/coordinode/client.py @@ -95,9 +95,7 @@ def __init__(self, proto_def: Any) -> None: self.unique: bool = proto_def.unique def __repr__(self) -> str: - return ( - f"PropertyDefinition(name={self.name!r}, type={self.type}, required={self.required}, unique={self.unique})" - ) + return f"PropertyDefinitionInfo(name={self.name!r}, type={self.type}, required={self.required}, unique={self.unique})" class LabelInfo: @@ -109,7 +107,7 @@ def __init__(self, proto_label: Any) -> None: self.properties: list[PropertyDefinitionInfo] = [PropertyDefinitionInfo(p) for p in proto_label.properties] def __repr__(self) -> str: - return f"LabelInfo(name={self.name!r}, properties={self.properties})" + return f"LabelInfo(name={self.name!r}, version={self.version}, properties={len(self.properties)})" class EdgeTypeInfo: @@ -121,7 +119,7 @@ def __init__(self, proto_edge_type: Any) -> None: self.properties: list[PropertyDefinitionInfo] = [PropertyDefinitionInfo(p) for p in proto_edge_type.properties] def __repr__(self) -> str: - return f"EdgeTypeInfo(name={self.name!r}, properties={self.properties})" + return f"EdgeTypeInfo(name={self.name!r}, version={self.version}, properties={len(self.properties)})" class TraverseResult: diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index ed9d95e..86acd22 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -228,14 +228,15 @@ def test_get_labels_returns_list(client): def test_get_labels_has_property_definitions(client): """LabelInfo.properties is a list (may be empty for schema-free labels).""" - client.cypher("MERGE (n:PropLabel {name: 'probe'})") + tag = uid() + client.cypher("CREATE (n:PropLabel {tag: $tag})", params={"tag": tag}) try: labels = client.get_labels() found = next((lbl for lbl in labels if lbl.name == "PropLabel"), None) assert found is not None, "PropLabel not returned by get_labels()" assert isinstance(found.properties, list) finally: - client.cypher("MATCH (n:PropLabel {name: 'probe'}) DELETE n") + client.cypher("MATCH (n:PropLabel {tag: $tag}) DETACH DELETE n", params={"tag": tag}) def test_get_edge_types_returns_list(client): @@ -277,8 +278,9 @@ def test_traverse_returns_neighbours(client): client.cypher("MATCH (n:TraverseRPC {tag: $tag}) DETACH DELETE n", params={"tag": tag}) -@pytest.mark.skip( - reason="CoordiNode Traverse RPC does not yet support inbound direction — server returns empty result set" +@pytest.mark.xfail( + strict=False, + reason="CoordiNode Traverse RPC does not yet support inbound direction — server returns empty result set", ) def test_traverse_inbound_direction(client): """traverse() with direction='inbound' reaches nodes that point TO start_id.""" From 4990122eea80f0f85198d63296153fa4106428f3 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 12 Apr 2026 23:48:26 +0300 Subject: [PATCH 05/16] fix(client): validate max_depth >= 1 in traverse(); xfail strict=True - traverse(): raise ValueError locally when max_depth < 1 before RPC - test_traverse_inbound_direction: strict=True so CI catches unexpected XPASS when server adds inbound direction support - test_get_labels_has_property_definitions: document why the assertion is intentionally weak (schema-free labels may have empty properties) --- coordinode/coordinode/client.py | 2 ++ tests/integration/test_sdk.py | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/coordinode/coordinode/client.py b/coordinode/coordinode/client.py index 51697b1..470ed1d 100644 --- a/coordinode/coordinode/client.py +++ b/coordinode/coordinode/client.py @@ -396,6 +396,8 @@ async def traverse( key = direction.lower() if key not in _direction_map: raise ValueError(f"Invalid direction {direction!r}. Must be one of: 'outbound', 'inbound', 'both'.") + if max_depth < 1: + raise ValueError(f"max_depth must be >= 1, got {max_depth!r}.") direction_value = _direction_map[key] req = TraverseRequest( diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index 86acd22..089ff06 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -234,6 +234,8 @@ def test_get_labels_has_property_definitions(client): labels = client.get_labels() found = next((lbl for lbl in labels if lbl.name == "PropLabel"), None) assert found is not None, "PropLabel not returned by get_labels()" + # Intentionally only check the type — CoordiNode is schema-free and may return + # an empty properties list even when the node was created with properties. assert isinstance(found.properties, list) finally: client.cypher("MATCH (n:PropLabel {tag: $tag}) DETACH DELETE n", params={"tag": tag}) @@ -279,7 +281,7 @@ def test_traverse_returns_neighbours(client): @pytest.mark.xfail( - strict=False, + strict=True, reason="CoordiNode Traverse RPC does not yet support inbound direction — server returns empty result set", ) def test_traverse_inbound_direction(client): From 0d7ce758a97d51d5761379a71cc604c36890d2d6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 00:05:14 +0300 Subject: [PATCH 06/16] test(integration): use unique names to avoid schema state false positives - Generate unique label and edge type names per test run so that stale schema entries from previous runs cannot cause spurious assertion hits - Change xfail marker to strict=False for inbound traverse test so CI reports XPASS as a warning instead of a hard failure when server eventually adds inbound support --- tests/integration/test_sdk.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index 089ff06..57a2bcf 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -214,16 +214,17 @@ def test_get_schema_text(client): def test_get_labels_returns_list(client): """get_labels() returns a non-empty list of LabelInfo after data is present.""" tag = uid() - client.cypher("CREATE (n:GetLabelsTest {tag: $tag})", params={"tag": tag}) + label_name = f"GetLabelsTest{uid()}" + client.cypher(f"CREATE (n:{label_name} {{tag: $tag}})", params={"tag": tag}) try: labels = client.get_labels() assert isinstance(labels, list) assert len(labels) > 0 assert all(isinstance(lbl, LabelInfo) for lbl in labels) names = [lbl.name for lbl in labels] - assert "GetLabelsTest" in names, f"GetLabelsTest not in {names}" + assert label_name in names, f"{label_name} not in {names}" finally: - client.cypher("MATCH (n:GetLabelsTest {tag: $tag}) DELETE n", params={"tag": tag}) + client.cypher(f"MATCH (n:{label_name} {{tag: $tag}}) DELETE n", params={"tag": tag}) def test_get_labels_has_property_definitions(client): @@ -244,8 +245,9 @@ def test_get_labels_has_property_definitions(client): def test_get_edge_types_returns_list(client): """get_edge_types() returns a non-empty list of EdgeTypeInfo after data is present.""" tag = uid() + edge_type = f"GET_EDGE_TYPE_TEST_{uid()}".upper() client.cypher( - "CREATE (a:EdgeTypeTestNode {tag: $tag})-[:GET_EDGE_TYPE_TEST]->(b:EdgeTypeTestNode {tag: $tag})", + f"CREATE (a:EdgeTypeTestNode {{tag: $tag}})-[:{edge_type}]->(b:EdgeTypeTestNode {{tag: $tag}})", params={"tag": tag}, ) try: @@ -254,7 +256,7 @@ def test_get_edge_types_returns_list(client): assert len(edge_types) > 0 assert all(isinstance(et, EdgeTypeInfo) for et in edge_types) type_names = [et.name for et in edge_types] - assert "GET_EDGE_TYPE_TEST" in type_names, f"GET_EDGE_TYPE_TEST not in {type_names}" + assert edge_type in type_names, f"{edge_type} not in {type_names}" finally: client.cypher("MATCH (n:EdgeTypeTestNode {tag: $tag}) DETACH DELETE n", params={"tag": tag}) @@ -281,7 +283,7 @@ def test_traverse_returns_neighbours(client): @pytest.mark.xfail( - strict=True, + strict=False, reason="CoordiNode Traverse RPC does not yet support inbound direction — server returns empty result set", ) def test_traverse_inbound_direction(client): From c9d0d272fd9c57c6de6a48d9f89c30a48231955a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 00:15:57 +0300 Subject: [PATCH 07/16] test(integration): use unique label name in has_property_definitions test Same stale-schema fix as adjacent tests: generate a unique label per run so accumulated schema state cannot produce a false assertion pass. --- tests/integration/test_sdk.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index 57a2bcf..e944501 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -230,16 +230,17 @@ def test_get_labels_returns_list(client): def test_get_labels_has_property_definitions(client): """LabelInfo.properties is a list (may be empty for schema-free labels).""" tag = uid() - client.cypher("CREATE (n:PropLabel {tag: $tag})", params={"tag": tag}) + label_name = f"PropLabel{uid()}" + client.cypher(f"CREATE (n:{label_name} {{tag: $tag}})", params={"tag": tag}) try: labels = client.get_labels() - found = next((lbl for lbl in labels if lbl.name == "PropLabel"), None) - assert found is not None, "PropLabel not returned by get_labels()" + found = next((lbl for lbl in labels if lbl.name == label_name), None) + assert found is not None, f"{label_name} not returned by get_labels()" # Intentionally only check the type — CoordiNode is schema-free and may return # an empty properties list even when the node was created with properties. assert isinstance(found.properties, list) finally: - client.cypher("MATCH (n:PropLabel {tag: $tag}) DETACH DELETE n", params={"tag": tag}) + client.cypher(f"MATCH (n:{label_name} {{tag: $tag}}) DETACH DELETE n", params={"tag": tag}) def test_get_edge_types_returns_list(client): From df5f83d5d429cd8b2e5b14880a41183fda118c05 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 00:35:17 +0300 Subject: [PATCH 08/16] test(integration): document why xfail uses strict=False for inbound traverse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XPASS means the server gained inbound support — that is good news, not a CI failure. strict=True would break CI at exactly the moment the server improves. The XPASS entry in pytest output is the signal to remove the marker. --- tests/integration/test_sdk.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index e944501..72d9200 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -285,6 +285,9 @@ def test_traverse_returns_neighbours(client): @pytest.mark.xfail( strict=False, + # strict=False: XPASS is good news (server gained inbound support), not an error. + # strict=True would break CI exactly when the server improves, which is undesirable. + # The XPASS report in pytest output is the signal to remove this marker. reason="CoordiNode Traverse RPC does not yet support inbound direction — server returns empty result set", ) def test_traverse_inbound_direction(client): From ff59dd1655fa00cb2247d61e89b4f4b6dd82eb7a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 01:32:31 +0300 Subject: [PATCH 09/16] test(integration): use DETACH DELETE in label cleanup for consistency Aligns test_get_labels_returns_list cleanup with the rest of the suite. DETACH DELETE is safe even if the node gains relationships in future tests. --- tests/integration/test_sdk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index 72d9200..8eb80c6 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -224,7 +224,7 @@ def test_get_labels_returns_list(client): names = [lbl.name for lbl in labels] assert label_name in names, f"{label_name} not in {names}" finally: - client.cypher(f"MATCH (n:{label_name} {{tag: $tag}}) DELETE n", params={"tag": tag}) + client.cypher(f"MATCH (n:{label_name} {{tag: $tag}}) DETACH DELETE n", params={"tag": tag}) def test_get_labels_has_property_definitions(client): From f69e7b760a3d53cb49ec48958263aa4317d860f3 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 01:45:42 +0300 Subject: [PATCH 10/16] test(unit): tighten repr assertions in test_repr_shows_counts Replace bare "1" and "0" substring checks with "nodes=1" and "edges=0" so the test catches incorrect counts like nodes=10 that would otherwise satisfy the weaker assertions. --- tests/unit/test_schema_crud.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_schema_crud.py b/tests/unit/test_schema_crud.py index 773bde5..83d3668 100644 --- a/tests/unit/test_schema_crud.py +++ b/tests/unit/test_schema_crud.py @@ -184,5 +184,5 @@ def test_repr_shows_counts(self): nodes = [_FakeNode(1, [])] result = TraverseResult(_FakeTraverseResponse(nodes=nodes)) r = repr(result) - assert "1" in r # 1 node - assert "0" in r # 0 edges + assert "nodes=1" in r + assert "edges=0" in r From 004eb229989828630382607409c9fc3476f17702 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 02:56:12 +0300 Subject: [PATCH 11/16] test(unit): replace magic number with named constant in TestEdgeTypeInfo Introduce PROPERTY_TYPE_TIMESTAMP = 6 as a class-level constant to replace the inline magic number that SonarCloud flagged as commented-out code. --- tests/unit/test_schema_crud.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_schema_crud.py b/tests/unit/test_schema_crud.py index 83d3668..7a59494 100644 --- a/tests/unit/test_schema_crud.py +++ b/tests/unit/test_schema_crud.py @@ -127,6 +127,8 @@ def test_version_zero(self): class TestEdgeTypeInfo: + PROPERTY_TYPE_TIMESTAMP = 6 + def test_basic_fields(self): et = EdgeTypeInfo(_FakeEdgeType("KNOWS", version=1)) assert et.name == "KNOWS" @@ -134,7 +136,7 @@ def test_basic_fields(self): assert et.properties == [] def test_properties_are_wrapped(self): - props = [_FakePropDef("since", 6)] # 6 = TIMESTAMP + props = [_FakePropDef("since", self.PROPERTY_TYPE_TIMESTAMP)] et = EdgeTypeInfo(_FakeEdgeType("FOLLOWS", properties=props)) assert len(et.properties) == 1 assert et.properties[0].name == "since" From c1f6ed9901b3a31f02fe75e86d84c16ad6b3b50d Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 03:20:06 +0300 Subject: [PATCH 12/16] test(unit): assert edge_id mapping in test_edges_are_wrapped_as_edge_results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edge_id → EdgeResult.id mapping was part of the wrapper contract but was not verified. Node mapping was tested (result.nodes[0].id == 1) but the equivalent edge check was missing. --- tests/unit/test_schema_crud.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_schema_crud.py b/tests/unit/test_schema_crud.py index 7a59494..6c3c003 100644 --- a/tests/unit/test_schema_crud.py +++ b/tests/unit/test_schema_crud.py @@ -168,6 +168,7 @@ def test_edges_are_wrapped_as_edge_results(self): result = TraverseResult(_FakeTraverseResponse(edges=edges)) assert len(result.edges) == 1 assert isinstance(result.edges[0], EdgeResult) + assert result.edges[0].id == 10 assert result.edges[0].source_id == 1 assert result.edges[0].target_id == 2 assert result.edges[0].type == "KNOWS" From b8a626ff77620824590fc4b944aa4817d95972b4 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 04:21:27 +0300 Subject: [PATCH 13/16] test: strengthen traverse assertions and add validation unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_traverse_returns_neighbours: assert the specific leaf1 node ID is in result.nodes, not just that the count is non-zero - test_traverse_inbound_direction: capture src_id alongside dst_id so that when the server gains inbound support (XPASS), the assertion validates the correct node was returned - TestTraverseValidation: new unit test class for ValueError cases in AsyncCoordinodeClient.traverse() — invalid direction and max_depth < 1; validation fires before any RPC so no running server is required --- tests/integration/test_sdk.py | 24 ++++++++++++++++++++--- tests/unit/test_schema_crud.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index 8eb80c6..dfcdbe0 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -276,9 +276,20 @@ def test_traverse_returns_neighbours(client): ) assert len(rows) >= 1, "hub node not found" start_id = rows[0]["node_id"] + + # Fetch the leaf1 node ID so we can assert it specifically appears in the result. + leaf_rows = client.cypher( + "MATCH (b:TraverseRPC {tag: $tag, role: 'leaf1'}) RETURN b AS node_id", + params={"tag": tag}, + ) + assert len(leaf_rows) >= 1, "leaf1 node not found" + leaf1_id = leaf_rows[0]["node_id"] + result = client.traverse(start_id, "TRAVERSE_TEST", direction="outbound", max_depth=1) assert isinstance(result, TraverseResult) assert len(result.nodes) >= 1, "traverse() returned no neighbour nodes" + node_ids = {n.id for n in result.nodes} + assert leaf1_id in node_ids, f"traverse() did not return the expected leaf1 node ({leaf1_id}); got: {node_ids}" finally: client.cypher("MATCH (n:TraverseRPC {tag: $tag}) DETACH DELETE n", params={"tag": tag}) @@ -298,16 +309,23 @@ def test_traverse_inbound_direction(client): params={"tag": tag}, ) try: - # Get dst node id — traverse INBOUND from dst should reach src. + # Capture both src and dst so that when the server gains inbound support + # (XPASS), the assertion verifies the *correct* node was returned, not just any node. rows = client.cypher( - "MATCH (src:TraverseIn {tag: $tag})-[:INBOUND_TEST]->(dst:TraverseIn {tag: $tag}) RETURN dst AS node_id", + "MATCH (src:TraverseIn {tag: $tag})-[:INBOUND_TEST]->(dst:TraverseIn {tag: $tag}) " + "RETURN src AS src_id, dst AS dst_id", params={"tag": tag}, ) assert len(rows) >= 1 - dst_id = rows[0]["node_id"] + src_id = rows[0]["src_id"] + dst_id = rows[0]["dst_id"] result = client.traverse(dst_id, "INBOUND_TEST", direction="inbound", max_depth=1) assert isinstance(result, TraverseResult) assert len(result.nodes) >= 1, "inbound traverse returned no nodes" + node_ids = {n.id for n in result.nodes} + assert src_id in node_ids, ( + f"inbound traverse did not return the expected source node ({src_id}); got: {node_ids}" + ) finally: client.cypher("MATCH (n:TraverseIn {tag: $tag}) DETACH DELETE n", params={"tag": tag}) diff --git a/tests/unit/test_schema_crud.py b/tests/unit/test_schema_crud.py index 6c3c003..3eddc82 100644 --- a/tests/unit/test_schema_crud.py +++ b/tests/unit/test_schema_crud.py @@ -5,7 +5,12 @@ interface that real generated messages provide. """ +import asyncio + +import pytest + from coordinode.client import ( + AsyncCoordinodeClient, EdgeResult, EdgeTypeInfo, LabelInfo, @@ -189,3 +194,34 @@ def test_repr_shows_counts(self): r = repr(result) assert "nodes=1" in r assert "edges=0" in r + + +# ── traverse() input validation ────────────────────────────────────────────── + + +class TestTraverseValidation: + """Unit tests for AsyncCoordinodeClient.traverse() input validation. + + Validation (direction and max_depth checks) runs before any RPC call, so no + running server is required — only the client object needs to be instantiated. + """ + + def test_invalid_direction_raises(self): + """traverse() raises ValueError for an unrecognised direction string.""" + + async def _inner() -> None: + client = AsyncCoordinodeClient("localhost:0") + with pytest.raises(ValueError, match="Invalid direction"): + await client.traverse(1, "KNOWS", direction="sideways") + + asyncio.run(_inner()) + + def test_max_depth_below_one_raises(self): + """traverse() raises ValueError when max_depth is less than 1.""" + + async def _inner() -> None: + client = AsyncCoordinodeClient("localhost:0") + with pytest.raises(ValueError, match="max_depth must be >= 1"): + await client.traverse(1, "KNOWS", max_depth=0) + + asyncio.run(_inner()) From f97a0fecb0414833003031db9a2396baaa8258b1 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 04:30:05 +0300 Subject: [PATCH 14/16] test(integration): narrow xfail to AssertionError for inbound traverse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit raises=AssertionError limits the xfail coverage to the known failure mode (server returns empty result → assertion fails). Unexpected errors such as gRPC RpcError or wrong enum mapping propagate as CI failures instead of being silently swallowed by the broad xfail marker. Addresses review thread #27. --- tests/integration/test_sdk.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/test_sdk.py b/tests/integration/test_sdk.py index dfcdbe0..fa14ce2 100644 --- a/tests/integration/test_sdk.py +++ b/tests/integration/test_sdk.py @@ -296,9 +296,13 @@ def test_traverse_returns_neighbours(client): @pytest.mark.xfail( strict=False, + raises=AssertionError, # strict=False: XPASS is good news (server gained inbound support), not an error. # strict=True would break CI exactly when the server improves, which is undesirable. # The XPASS report in pytest output is the signal to remove this marker. + # raises=AssertionError: narrows xfail to the known failure mode (empty result set → + # assertion fails). Unexpected errors (gRPC RpcError, wrong enum, etc.) are NOT covered + # and will still propagate as CI failures. reason="CoordiNode Traverse RPC does not yet support inbound direction — server returns empty result set", ) def test_traverse_inbound_direction(client): From cd3f3d7e506c819227498998072c582dd3999a71 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 04:47:26 +0300 Subject: [PATCH 15/16] refactor(client): validate direction/max_depth before proto import in traverse() Pure string/int validation now fires before the deferred proto import. Callers get ValueError for invalid inputs even when proto stubs have not been generated, keeping validation independent of the build step. Addresses review thread #28. --- coordinode/coordinode/client.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/coordinode/coordinode/client.py b/coordinode/coordinode/client.py index 470ed1d..1a7e9da 100644 --- a/coordinode/coordinode/client.py +++ b/coordinode/coordinode/client.py @@ -383,6 +383,15 @@ async def traverse( Returns: :class:`TraverseResult` with ``nodes`` and ``edges`` lists. """ + # Validate pure string/int inputs before importing proto stubs — ensures ValueError + # is raised even when proto stubs have not been generated yet. + _valid_directions = {"outbound", "inbound", "both"} + key = direction.lower() + if key not in _valid_directions: + raise ValueError(f"Invalid direction {direction!r}. Must be one of: 'outbound', 'inbound', 'both'.") + if max_depth < 1: + raise ValueError(f"max_depth must be >= 1, got {max_depth!r}.") + from coordinode._proto.coordinode.v1.graph.graph_pb2 import ( # type: ignore[import] TraversalDirection, TraverseRequest, @@ -393,11 +402,6 @@ async def traverse( "inbound": TraversalDirection.TRAVERSAL_DIRECTION_INBOUND, "both": TraversalDirection.TRAVERSAL_DIRECTION_BOTH, } - key = direction.lower() - if key not in _direction_map: - raise ValueError(f"Invalid direction {direction!r}. Must be one of: 'outbound', 'inbound', 'both'.") - if max_depth < 1: - raise ValueError(f"max_depth must be >= 1, got {max_depth!r}.") direction_value = _direction_map[key] req = TraverseRequest( From ae3710676fd5564876b806a2e63805d3698556b4 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Mon, 13 Apr 2026 05:00:15 +0300 Subject: [PATCH 16/16] fix(client): add type guards for direction and max_depth in traverse() Passing direction=None raised AttributeError; passing max_depth='2' or max_depth=True raised TypeError. Both now raise ValueError consistently, matching the documented intent of the validation block. - isinstance(direction, str) check before .lower() call - isinstance(max_depth, int) and not isinstance(max_depth, bool) before < 1 - Three new unit tests cover None direction, str max_depth, bool max_depth Addresses review thread #29. --- coordinode/coordinode/client.py | 8 ++++++-- tests/unit/test_schema_crud.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/coordinode/coordinode/client.py b/coordinode/coordinode/client.py index 1a7e9da..b270dd1 100644 --- a/coordinode/coordinode/client.py +++ b/coordinode/coordinode/client.py @@ -385,12 +385,16 @@ async def traverse( """ # Validate pure string/int inputs before importing proto stubs — ensures ValueError # is raised even when proto stubs have not been generated yet. + # Type guards come first so that wrong types raise ValueError, not AttributeError/TypeError. + if not isinstance(direction, str): + raise ValueError(f"direction must be a str, got {type(direction).__name__!r}.") _valid_directions = {"outbound", "inbound", "both"} key = direction.lower() if key not in _valid_directions: raise ValueError(f"Invalid direction {direction!r}. Must be one of: 'outbound', 'inbound', 'both'.") - if max_depth < 1: - raise ValueError(f"max_depth must be >= 1, got {max_depth!r}.") + # bool is a subclass of int in Python, so `isinstance(True, int)` is True — exclude it. + if not isinstance(max_depth, int) or isinstance(max_depth, bool) or max_depth < 1: + raise ValueError(f"max_depth must be an integer >= 1, got {max_depth!r}.") from coordinode._proto.coordinode.v1.graph.graph_pb2 import ( # type: ignore[import] TraversalDirection, diff --git a/tests/unit/test_schema_crud.py b/tests/unit/test_schema_crud.py index 3eddc82..3e6495c 100644 --- a/tests/unit/test_schema_crud.py +++ b/tests/unit/test_schema_crud.py @@ -221,7 +221,37 @@ def test_max_depth_below_one_raises(self): async def _inner() -> None: client = AsyncCoordinodeClient("localhost:0") - with pytest.raises(ValueError, match="max_depth must be >= 1"): + with pytest.raises(ValueError, match="max_depth must be"): await client.traverse(1, "KNOWS", max_depth=0) asyncio.run(_inner()) + + def test_direction_none_raises_value_error(self): + """traverse() raises ValueError (not AttributeError) when direction is None.""" + + async def _inner() -> None: + client = AsyncCoordinodeClient("localhost:0") + with pytest.raises(ValueError, match="direction must be a str"): + await client.traverse(1, "KNOWS", direction=None) # type: ignore[arg-type] + + asyncio.run(_inner()) + + def test_max_depth_string_raises_value_error(self): + """traverse() raises ValueError (not TypeError) when max_depth is a string.""" + + async def _inner() -> None: + client = AsyncCoordinodeClient("localhost:0") + with pytest.raises(ValueError, match="max_depth must be an integer"): + await client.traverse(1, "KNOWS", max_depth="2") # type: ignore[arg-type] + + asyncio.run(_inner()) + + def test_max_depth_bool_raises_value_error(self): + """traverse() raises ValueError for bool max_depth (bool is a subclass of int in Python).""" + + async def _inner() -> None: + client = AsyncCoordinodeClient("localhost:0") + with pytest.raises(ValueError, match="max_depth must be an integer"): + await client.traverse(1, "KNOWS", max_depth=True) # type: ignore[arg-type] + + asyncio.run(_inner())