inputlayer-client-dev 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ """WebSocket connection management with authentication and streaming support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Any
8
+
9
+ import websockets
10
+ from websockets.asyncio.client import ClientConnection
11
+
12
+ from inputlayer._protocol import (
13
+ AuthenticateMessage,
14
+ AuthenticatedResponse,
15
+ AuthErrorResponse,
16
+ ErrorResponse,
17
+ ExecuteMessage,
18
+ LoginMessage,
19
+ NotificationResponse,
20
+ PingMessage,
21
+ PongResponse,
22
+ ResultChunkResponse,
23
+ ResultEndResponse,
24
+ ResultResponse,
25
+ ResultStartResponse,
26
+ ServerMessage,
27
+ deserialize_message,
28
+ )
29
+ from inputlayer.exceptions import (
30
+ AuthenticationError,
31
+ ConnectionError,
32
+ InternalError,
33
+ )
34
+ from inputlayer.notifications import NotificationDispatcher, NotificationEvent
35
+
36
+ logger = logging.getLogger("inputlayer")
37
+
38
+
39
+ class Connection:
40
+ """Manages the WebSocket connection to an InputLayer server."""
41
+
42
+ def __init__(
43
+ self,
44
+ url: str,
45
+ *,
46
+ username: str | None = None,
47
+ password: str | None = None,
48
+ api_key: str | None = None,
49
+ auto_reconnect: bool = True,
50
+ reconnect_delay: float = 1.0,
51
+ max_reconnect_attempts: int = 10,
52
+ initial_kg: str | None = None,
53
+ last_seq: int | None = None,
54
+ ) -> None:
55
+ self._url = url
56
+ self._username = username
57
+ self._password = password
58
+ self._api_key = api_key
59
+ self._auto_reconnect = auto_reconnect
60
+ self._reconnect_delay = reconnect_delay
61
+ self._max_reconnect_attempts = max_reconnect_attempts
62
+ self._initial_kg = initial_kg
63
+ self._last_seq = last_seq
64
+
65
+ self._ws: ClientConnection | None = None
66
+ self._session_id: str | None = None
67
+ self._server_version: str | None = None
68
+ self._role: str | None = None
69
+ self._current_kg: str | None = None
70
+ self._connected = False
71
+
72
+ self._dispatcher = NotificationDispatcher()
73
+ self._recv_task: asyncio.Task | None = None
74
+
75
+ # ── Properties ────────────────────────────────────────────────────
76
+
77
+ @property
78
+ def connected(self) -> bool:
79
+ return self._connected
80
+
81
+ @property
82
+ def session_id(self) -> str | None:
83
+ return self._session_id
84
+
85
+ @property
86
+ def server_version(self) -> str | None:
87
+ return self._server_version
88
+
89
+ @property
90
+ def role(self) -> str | None:
91
+ return self._role
92
+
93
+ @property
94
+ def current_kg(self) -> str | None:
95
+ return self._current_kg
96
+
97
+ @property
98
+ def dispatcher(self) -> NotificationDispatcher:
99
+ return self._dispatcher
100
+
101
+ @property
102
+ def last_seq(self) -> int:
103
+ return self._dispatcher.last_seq
104
+
105
+ # ── Connection lifecycle ──────────────────────────────────────────
106
+
107
+ async def connect(self) -> None:
108
+ """Connect and authenticate."""
109
+ ws_url = self._url
110
+ params = []
111
+ if self._initial_kg:
112
+ params.append(f"kg={self._initial_kg}")
113
+ if self._last_seq is not None:
114
+ params.append(f"last_seq={self._last_seq}")
115
+ if params:
116
+ separator = "&" if "?" in ws_url else "?"
117
+ ws_url = f"{ws_url}{separator}{'&'.join(params)}"
118
+
119
+ try:
120
+ self._ws = await websockets.connect(ws_url)
121
+ except Exception as e:
122
+ raise ConnectionError(f"Failed to connect to {ws_url}: {e}") from e
123
+
124
+ await self._authenticate()
125
+ self._connected = True
126
+
127
+ # Start background receiver for notifications
128
+ self._recv_task = asyncio.create_task(self._receive_loop())
129
+
130
+ async def close(self) -> None:
131
+ """Close the connection gracefully."""
132
+ self._connected = False
133
+ if self._recv_task and not self._recv_task.done():
134
+ self._recv_task.cancel()
135
+ try:
136
+ await self._recv_task
137
+ except asyncio.CancelledError:
138
+ pass
139
+ if self._ws:
140
+ try:
141
+ await self._ws.close()
142
+ except Exception:
143
+ pass
144
+ self._ws = None
145
+
146
+ async def _authenticate(self) -> None:
147
+ """Send authentication message and wait for response."""
148
+ assert self._ws is not None
149
+
150
+ if self._api_key:
151
+ msg = AuthenticateMessage(api_key=self._api_key)
152
+ elif self._username and self._password:
153
+ msg = LoginMessage(username=self._username, password=self._password)
154
+ else:
155
+ raise AuthenticationError("No credentials provided (need username/password or api_key)")
156
+
157
+ await self._ws.send(msg.to_json())
158
+ raw = await self._ws.recv()
159
+ response = deserialize_message(raw)
160
+
161
+ if isinstance(response, AuthErrorResponse):
162
+ raise AuthenticationError(response.message)
163
+ if isinstance(response, AuthenticatedResponse):
164
+ self._session_id = response.session_id
165
+ self._server_version = response.version
166
+ self._role = response.role
167
+ self._current_kg = response.knowledge_graph
168
+ return
169
+
170
+ raise AuthenticationError(f"Unexpected auth response: {response!r}")
171
+
172
+ # ── Command execution ─────────────────────────────────────────────
173
+
174
+ async def execute(self, program: str) -> ResultResponse:
175
+ """Send a program/command and wait for the result.
176
+
177
+ Transparently assembles streamed results (result_start → chunks → result_end).
178
+ """
179
+ if not self._connected or not self._ws:
180
+ raise ConnectionError("Not connected")
181
+
182
+ msg = ExecuteMessage(program=program)
183
+ await self._ws.send(msg.to_json())
184
+
185
+ return await self._read_result()
186
+
187
+ async def _read_result(self) -> ResultResponse:
188
+ """Read messages until we get a complete result, dispatching notifications."""
189
+ assert self._ws is not None
190
+ while True:
191
+ raw = await self._ws.recv()
192
+ response = deserialize_message(raw)
193
+
194
+ if isinstance(response, NotificationResponse):
195
+ self._dispatch_notification(response)
196
+ continue
197
+
198
+ if isinstance(response, PongResponse):
199
+ continue
200
+
201
+ if isinstance(response, ResultResponse):
202
+ if response.switched_kg:
203
+ self._current_kg = response.switched_kg
204
+ return response
205
+
206
+ if isinstance(response, ErrorResponse):
207
+ return ResultResponse(
208
+ columns=["error"],
209
+ rows=[[response.message]],
210
+ row_count=1,
211
+ total_count=1,
212
+ truncated=False,
213
+ execution_time_ms=0,
214
+ )
215
+
216
+ if isinstance(response, ResultStartResponse):
217
+ return await self._assemble_stream(response)
218
+
219
+ raise InternalError(f"Unexpected message during result read: {response!r}")
220
+
221
+ async def _assemble_stream(self, start: ResultStartResponse) -> ResultResponse:
222
+ """Assemble a streamed result from chunks."""
223
+ assert self._ws is not None
224
+ all_rows: list[list[Any]] = []
225
+ all_provenance: list[str] = []
226
+
227
+ while True:
228
+ raw = await self._ws.recv()
229
+ response = deserialize_message(raw)
230
+
231
+ if isinstance(response, NotificationResponse):
232
+ self._dispatch_notification(response)
233
+ continue
234
+
235
+ if isinstance(response, ResultChunkResponse):
236
+ all_rows.extend(response.rows)
237
+ if response.row_provenance:
238
+ all_provenance.extend(response.row_provenance)
239
+ continue
240
+
241
+ if isinstance(response, ResultEndResponse):
242
+ if start.switched_kg:
243
+ self._current_kg = start.switched_kg
244
+ return ResultResponse(
245
+ columns=start.columns,
246
+ rows=all_rows,
247
+ row_count=response.row_count,
248
+ total_count=start.total_count,
249
+ truncated=start.truncated,
250
+ execution_time_ms=start.execution_time_ms,
251
+ row_provenance=all_provenance or None,
252
+ metadata=start.metadata,
253
+ switched_kg=start.switched_kg,
254
+ )
255
+
256
+ raise InternalError(f"Unexpected message during streaming: {response!r}")
257
+
258
+ # ── Notification handling ─────────────────────────────────────────
259
+
260
+ def _dispatch_notification(self, notif: NotificationResponse) -> None:
261
+ event = NotificationEvent(
262
+ type=notif.type,
263
+ seq=notif.seq,
264
+ timestamp_ms=notif.timestamp_ms,
265
+ session_id=notif.session_id,
266
+ knowledge_graph=notif.knowledge_graph,
267
+ relation=notif.relation,
268
+ operation=notif.operation,
269
+ count=notif.count,
270
+ rule_name=notif.rule_name,
271
+ entity=notif.entity,
272
+ )
273
+ self._dispatcher.dispatch(event)
274
+
275
+ async def _receive_loop(self) -> None:
276
+ """Background task that receives notifications when idle."""
277
+ assert self._ws is not None
278
+ try:
279
+ async for raw in self._ws:
280
+ try:
281
+ response = deserialize_message(raw)
282
+ if isinstance(response, NotificationResponse):
283
+ self._dispatch_notification(response)
284
+ except Exception:
285
+ pass
286
+ except asyncio.CancelledError:
287
+ pass
288
+ except Exception:
289
+ self._connected = False
290
+ if self._auto_reconnect:
291
+ await self._reconnect()
292
+
293
+ async def _reconnect(self) -> None:
294
+ """Attempt reconnection with exponential backoff."""
295
+ delay = self._reconnect_delay
296
+ for attempt in range(self._max_reconnect_attempts):
297
+ logger.info(f"Reconnecting (attempt {attempt + 1}/{self._max_reconnect_attempts})...")
298
+ await asyncio.sleep(delay)
299
+ try:
300
+ self._last_seq = self._dispatcher.last_seq
301
+ await self.connect()
302
+ logger.info("Reconnected successfully")
303
+ return
304
+ except Exception:
305
+ delay = min(delay * 2, 60.0)
306
+ raise ConnectionError(
307
+ f"Failed to reconnect after {self._max_reconnect_attempts} attempts"
308
+ )
309
+
310
+ async def ping(self) -> None:
311
+ """Send a keep-alive ping."""
312
+ if not self._ws:
313
+ raise ConnectionError("Not connected")
314
+ await self._ws.send(PingMessage().to_json())
inputlayer/derived.py ADDED
@@ -0,0 +1,141 @@
1
+ """Derived relations and the From/Where/Select rule builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any, ClassVar
7
+
8
+ from inputlayer._ast import BoolExpr, Expr
9
+ from inputlayer._proxy import ColumnProxy, RelationProxy, RelationRef
10
+ from inputlayer.relation import Relation
11
+
12
+ if TYPE_CHECKING:
13
+ pass
14
+
15
+
16
+ class Derived(Relation):
17
+ """Base class for derived (rule-computed) relations.
18
+
19
+ Subclass with ``rules: ClassVar[list[RuleClause]]``::
20
+
21
+ class Reachable(Derived):
22
+ src: int
23
+ dst: int
24
+
25
+ rules = [
26
+ From(Edge).select(src=Edge.x, dst=Edge.y),
27
+ From(Reachable, Edge)
28
+ .where(lambda r, e: r.dst == e.x)
29
+ .select(src=Reachable.src, dst=Edge.y),
30
+ ]
31
+ """
32
+
33
+ rules: ClassVar[list[RuleClause]]
34
+
35
+
36
+ @dataclass
37
+ class RuleClause:
38
+ """A single compiled rule clause: head column map + body relations + condition."""
39
+
40
+ relations: list[tuple[str, type[Relation], str | None]] # (name, cls, alias)
41
+ select_map: dict[str, Expr] # head_column → body Expr
42
+ condition: BoolExpr | None = None
43
+
44
+
45
+ @dataclass
46
+ class _FromBase:
47
+ """Internal: holds the relations for a From(...) builder."""
48
+
49
+ _relations: list[tuple[str, type[Relation], str | None]]
50
+
51
+ def _build_proxy_args(self) -> list[RelationProxy]:
52
+ """Build proxy objects matching the From(...) arguments."""
53
+ proxies = []
54
+ for rn, cls, alias in self._relations:
55
+ proxies.append(RelationProxy(rn, ref_alias=alias))
56
+ return proxies
57
+
58
+
59
+ class FromWhere(_FromBase):
60
+ """Intermediate builder after .where() - only .select() remains."""
61
+
62
+ _condition: BoolExpr
63
+
64
+ def __init__(
65
+ self,
66
+ relations: list[tuple[str, type[Relation], str | None]],
67
+ condition: BoolExpr,
68
+ ) -> None:
69
+ self._relations = relations
70
+ self._condition = condition
71
+
72
+ def select(self, **columns: ColumnProxy | Expr) -> RuleClause:
73
+ """Map derived columns to body expressions.
74
+
75
+ Keyword argument names must match the Derived class field names.
76
+ """
77
+ select_map: dict[str, Expr] = {}
78
+ for name, val in columns.items():
79
+ if isinstance(val, ColumnProxy):
80
+ select_map[name] = val._to_ast()
81
+ elif isinstance(val, Expr):
82
+ select_map[name] = val
83
+ else:
84
+ raise TypeError(
85
+ f"select() value for '{name}' must be a Column or Expr, "
86
+ f"got {type(val).__name__}"
87
+ )
88
+ return RuleClause(
89
+ relations=self._relations,
90
+ select_map=select_map,
91
+ condition=self._condition,
92
+ )
93
+
94
+
95
+ class From(_FromBase):
96
+ """Rule builder: From(Relation1, Relation2, ...).where(...).select(...)"""
97
+
98
+ def __init__(self, *relations: type[Relation] | RelationRef) -> None:
99
+ self._relations = []
100
+ for r in relations:
101
+ if isinstance(r, RelationRef):
102
+ self._relations.append((r.relation_name, r.relation_cls, r.alias))
103
+ elif isinstance(r, type) and issubclass(r, Relation):
104
+ rn = Relation._resolve_name(r)
105
+ self._relations.append((rn, r, None))
106
+ else:
107
+ raise TypeError(
108
+ f"From() expects Relation subclasses or RelationRef, "
109
+ f"got {type(r).__name__}"
110
+ )
111
+
112
+ def where(self, condition: Any) -> FromWhere:
113
+ """Add a filter condition. Accepts a BoolExpr or a lambda taking proxies."""
114
+ if callable(condition) and not isinstance(condition, BoolExpr):
115
+ proxies = self._build_proxy_args()
116
+ condition = condition(*proxies)
117
+ if not isinstance(condition, BoolExpr):
118
+ raise TypeError(
119
+ f"where() condition must be a BoolExpr or callable returning BoolExpr, "
120
+ f"got {type(condition).__name__}"
121
+ )
122
+ return FromWhere(self._relations, condition)
123
+
124
+ def select(self, **columns: ColumnProxy | Expr) -> RuleClause:
125
+ """Map derived columns to body expressions (no filter)."""
126
+ select_map: dict[str, Expr] = {}
127
+ for name, val in columns.items():
128
+ if isinstance(val, ColumnProxy):
129
+ select_map[name] = val._to_ast()
130
+ elif isinstance(val, Expr):
131
+ select_map[name] = val
132
+ else:
133
+ raise TypeError(
134
+ f"select() value for '{name}' must be a Column or Expr, "
135
+ f"got {type(val).__name__}"
136
+ )
137
+ return RuleClause(
138
+ relations=self._relations,
139
+ select_map=select_map,
140
+ condition=None,
141
+ )
@@ -0,0 +1,76 @@
1
+ """Exception hierarchy for the InputLayer OLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class InputLayerError(Exception):
7
+ """Base exception for all InputLayer errors."""
8
+
9
+
10
+ class ConnectionError(InputLayerError):
11
+ """Failed to connect or lost connection to the server."""
12
+
13
+
14
+ class AuthenticationError(InputLayerError):
15
+ """Authentication failed (bad credentials or API key)."""
16
+
17
+
18
+ class SchemaConflictError(InputLayerError):
19
+ """Schema definition conflicts with an existing schema."""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ existing_schema: dict | None = None,
26
+ proposed_schema: dict | None = None,
27
+ conflicts: list[str] | None = None,
28
+ ) -> None:
29
+ super().__init__(message)
30
+ self.existing_schema = existing_schema
31
+ self.proposed_schema = proposed_schema
32
+ self.conflicts = conflicts or []
33
+
34
+
35
+ class ValidationError(InputLayerError):
36
+ """Data validation failed (type mismatch, constraint violation)."""
37
+
38
+ def __init__(self, message: str, *, details: list[dict] | None = None) -> None:
39
+ super().__init__(message)
40
+ self.details = details or []
41
+
42
+
43
+ class QueryTimeoutError(InputLayerError):
44
+ """Query exceeded the configured timeout."""
45
+
46
+
47
+ class PermissionError(InputLayerError):
48
+ """Insufficient permissions for the requested operation."""
49
+
50
+
51
+ class KnowledgeGraphNotFoundError(InputLayerError):
52
+ """The specified knowledge graph does not exist."""
53
+
54
+
55
+ class KnowledgeGraphExistsError(InputLayerError):
56
+ """The knowledge graph already exists."""
57
+
58
+
59
+ class CannotDropError(InputLayerError):
60
+ """Cannot drop the target (e.g., default KG, currently bound KG)."""
61
+
62
+
63
+ class RelationNotFoundError(InputLayerError):
64
+ """The specified relation does not exist."""
65
+
66
+
67
+ class RuleNotFoundError(InputLayerError):
68
+ """The specified rule does not exist."""
69
+
70
+
71
+ class IndexNotFoundError(InputLayerError):
72
+ """The specified index does not exist."""
73
+
74
+
75
+ class InternalError(InputLayerError):
76
+ """An unexpected internal error occurred."""