Skip to content

Commit a704de5

Browse files
committed
fix(client,tests,langchain): port sentinel, lazy connect, schema parser
- AsyncCoordinodeClient: port default None → distinguishes omitted from explicit 7080; fixes silent conflict when caller passes explicit port matching 7080 but host string embeds different port - CoordinodeClient: same port sentinel change; add lazy connect in _run() so client is usable outside context manager; add public close() method reused by __exit__ (fixes AttributeError in langchain/llama-index teardown) - Host:port parsing tests: use AsyncCoordinodeClient directly (no event loop created → no loop leak); add test for explicit-port-conflict raise - test_sdk.py: remove stale "xfail until wired" section comment - test_types.py: rename _FakeMap.fields → entries to match proto accessor - langchain graph.py: rewrite _parse_schema to match get_schema_text() format ("Edge types:" header, inline "(properties: ...)" on bullet lines)
1 parent 3811680 commit a704de5

4 files changed

Lines changed: 73 additions & 57 deletions

File tree

coordinode/client.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,24 +106,27 @@ class AsyncCoordinodeClient:
106106
def __init__(
107107
self,
108108
host: str = "localhost",
109-
port: int = 7080,
109+
port: int | None = None,
110110
*,
111111
tls: bool = False,
112112
timeout: float = 30.0,
113113
) -> None:
114114
# Support "host:port" as a single string (common gRPC convention).
115115
# _HOST_PORT_RE matches "hostname:port" and "[IPv6]:port" but not bare
116116
# IPv6 addresses, avoiding the ambiguity of rsplit(":", 1) on "::1".
117+
# port=None means "not specified by caller" — distinct from explicit port=7080.
117118
m = _HOST_PORT_RE.match(host)
118119
if m:
119120
parsed_port = int(m.group(2))
120-
if port != 7080 and port != parsed_port:
121+
if port is not None and port != parsed_port:
121122
raise ValueError(
122123
f"Conflicting ports: port={port!r} (argument) vs {parsed_port!r} "
123124
f"(embedded in host={host!r}). Specify the port in the host string "
124125
"only, or use the port argument only."
125126
)
126127
host, port = m.group(1), parsed_port
128+
if port is None:
129+
port = 7080
127130
self._host = host
128131
self._port = port
129132
self._tls = tls
@@ -336,23 +339,36 @@ class CoordinodeClient:
336339
def __init__(
337340
self,
338341
host: str = "localhost",
339-
port: int = 7080,
342+
port: int | None = None,
340343
*,
341344
tls: bool = False,
342345
timeout: float = 30.0,
343346
) -> None:
344347
self._async = AsyncCoordinodeClient(host, port, tls=tls, timeout=timeout)
345348
self._loop = asyncio.new_event_loop()
349+
self._connected = False
346350

347351
def __enter__(self) -> CoordinodeClient:
348-
self._loop.run_until_complete(self._async.connect())
352+
if not self._connected:
353+
self._loop.run_until_complete(self._async.connect())
354+
self._connected = True
349355
return self
350356

351357
def __exit__(self, *_: Any) -> None:
352-
self._loop.run_until_complete(self._async.close())
353-
self._loop.close()
358+
self.close()
359+
360+
def close(self) -> None:
361+
"""Close the underlying gRPC channel and event loop."""
362+
if self._connected:
363+
self._loop.run_until_complete(self._async.close())
364+
self._connected = False
365+
if not self._loop.is_closed():
366+
self._loop.close()
354367

355368
def _run(self, coro: Any) -> Any:
369+
if not self._connected:
370+
self._loop.run_until_complete(self._async.connect())
371+
self._connected = True
356372
return self._loop.run_until_complete(coro)
357373

358374
def cypher(

langchain-coordinode/langchain_coordinode/graph.py

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import re
56
from typing import Any
67

78
from langchain_community.graphs.graph_store import GraphStore
@@ -113,16 +114,23 @@ def _parse_schema(schema_text: str) -> dict[str, Any]:
113114
"relationships": [{"start": "A", "type": "REL", "end": "B"}, ...],
114115
}
115116
116-
CoordiNode's ``/schema`` endpoint returns a human-readable text; we do a
117-
best-effort parse here. For reliable structured access use the gRPC
118-
``SchemaService`` directly.
117+
CoordiNode's schema text format (from ``get_schema_text()``)::
118+
119+
Node labels:
120+
- Person (properties: name: STRING, age: INT64)
121+
- Company
122+
123+
Edge types:
124+
- KNOWS (properties: since: INT64)
125+
- WORKS_FOR
126+
127+
We parse inline ``(properties: ...)`` lists on each bullet line.
128+
For reliable structured access use the gRPC ``SchemaService`` directly.
119129
"""
120130
node_props: dict[str, list[dict[str, str]]] = {}
121131
rel_props: dict[str, list[dict[str, str]]] = {}
122132
relationships: list[dict[str, str]] = []
123133

124-
current_label: str | None = None
125-
current_type: str | None = None
126134
in_nodes = False
127135
in_rels = False
128136

@@ -134,42 +142,28 @@ def _parse_schema(schema_text: str) -> dict[str, Any]:
134142
if stripped.lower().startswith("node labels"):
135143
in_nodes, in_rels = True, False
136144
continue
137-
if stripped.lower().startswith("relationship types"):
145+
# Accept both "Edge types:" (current format) and "Relationship types:" (legacy)
146+
if stripped.lower().startswith("edge types") or stripped.lower().startswith("relationship types"):
138147
in_nodes, in_rels = False, True
139148
continue
140149

141-
if in_nodes:
142-
if stripped.startswith("-") or stripped.startswith("*"):
143-
label = stripped.lstrip("-* ").split()[0].strip(":")
144-
current_label = label
145-
node_props.setdefault(label, [])
146-
elif current_label and ":" in stripped:
147-
parts = stripped.split(":", 1)
148-
prop = parts[0].strip()
149-
typ = parts[1].strip().upper()
150-
node_props[current_label].append({"property": prop, "type": typ})
151-
152-
if in_rels:
153-
if stripped.startswith("-") or stripped.startswith("*"):
154-
rel = stripped.lstrip("-* ").split()[0].strip()
155-
current_type = rel
156-
rel_props.setdefault(rel, [])
157-
elif current_type and "->" in stripped:
158-
parts = stripped.split("->")
159-
start = parts[0].strip().strip("(: )")
160-
end = parts[-1].strip().strip("(: )")
161-
relationships.append(
162-
{
163-
"start": start,
164-
"type": current_type,
165-
"end": end,
166-
}
167-
)
168-
elif current_type and ":" in stripped:
169-
parts = stripped.split(":", 1)
170-
prop = parts[0].strip()
171-
typ = parts[1].strip().upper()
172-
rel_props[current_type].append({"property": prop, "type": typ})
150+
if (in_nodes or in_rels) and (stripped.startswith("-") or stripped.startswith("*")):
151+
# Extract name (part before optional "(properties: ...)")
152+
name = stripped.lstrip("-* ").split("(")[0].strip()
153+
if not name:
154+
continue
155+
# Parse inline properties: "- Label (properties: prop1: TYPE, prop2: TYPE)"
156+
props: list[dict[str, str]] = []
157+
m = re.search(r"\(properties:\s*([^)]+)\)", stripped)
158+
if m:
159+
for prop_str in m.group(1).split(","):
160+
kv = prop_str.strip().split(":", 1)
161+
if len(kv) == 2:
162+
props.append({"property": kv[0].strip(), "type": kv[1].strip()})
163+
if in_nodes:
164+
node_props[name] = props
165+
else:
166+
rel_props[name] = props
173167

174168
return {
175169
"node_props": node_props,

tests/integration/test_sdk.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -256,24 +256,30 @@ def test_hybrid_search_returns_results(client):
256256

257257

258258
def test_hostport_string_parsing():
259-
"""CoordinodeClient("host:port") must parse correctly."""
260-
c = CoordinodeClient("localhost:7080")
261-
assert c._async._host == "localhost"
262-
assert c._async._port == 7080
259+
"""AsyncCoordinodeClient("host:port") must parse correctly."""
260+
c = AsyncCoordinodeClient("localhost:7080")
261+
assert c._host == "localhost"
262+
assert c._port == 7080
263263

264264

265265
def test_ipv6_bracket_parsing():
266266
"""Bracketed IPv6 [::1]:7080 must parse correctly."""
267-
c = CoordinodeClient("[::1]:7080")
268-
assert c._async._host == "[::1]"
269-
assert c._async._port == 7080
267+
c = AsyncCoordinodeClient("[::1]:7080")
268+
assert c._host == "[::1]"
269+
assert c._port == 7080
270270

271271

272272
def test_bare_ipv6_not_parsed():
273273
"""Unbracketed IPv6 must NOT be misinterpreted as host:port."""
274-
c = CoordinodeClient("::1")
275-
assert c._async._host == "::1"
276-
assert c._async._port == 7080 # default unchanged
274+
c = AsyncCoordinodeClient("::1")
275+
assert c._host == "::1"
276+
assert c._port == 7080 # default unchanged
277+
278+
279+
def test_explicit_port_conflict_raises():
280+
"""Explicit port that conflicts with host-embedded port must raise ValueError."""
281+
with pytest.raises(ValueError, match="Conflicting ports"):
282+
AsyncCoordinodeClient("db.example.com:7443", port=7080)
277283

278284

279285
# ── Async client ──────────────────────────────────────────────────────────────
@@ -303,7 +309,7 @@ async def test_async_create_node():
303309
await c.cypher("MATCH (n:AsyncTest {tag: $tag}) DELETE n", params={"tag": tag})
304310

305311

306-
# ── Vector search (xfail until wired) ─────────────────────────────────────────
312+
# ── Vector search ─────────────────────────────────────────────────────────────
307313

308314

309315
def test_vector_search_returns_results(client):

tests/unit/test_types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ def __init__(self, values):
3636

3737

3838
class _FakeMap:
39-
def __init__(self, fields):
40-
self.fields = dict(fields)
39+
def __init__(self, entries):
40+
self.entries = dict(entries)
4141

4242

4343
class _FakePV:

0 commit comments

Comments
 (0)