anamdb 1.0.0__tar.gz

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,24 @@
1
+ /target
2
+ *.lance
3
+ *.onnx
4
+ .env
5
+ *.swp
6
+ *.swo
7
+ .DS_Store
8
+ ROADMAP.md
9
+
10
+ # Docs / Node
11
+ docs/node_modules/
12
+ docs/.vitepress/dist/
13
+ docs/.vitepress/cache/
14
+
15
+ # Python
16
+ __pycache__/
17
+ *.pyc
18
+ *.pyo
19
+ *.pyd
20
+ *.egg-info/
21
+ dist/
22
+ build/
23
+ .venv/
24
+ .pytest_cache/
anamdb-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: anamdb
3
+ Version: 1.0.0
4
+ Summary: Python client for AnamDB — the AI-native neurosymbolic database engine.
5
+ Project-URL: Homepage, https://jorge-nexsys.github.io/anam
6
+ Project-URL: Repository, https://github.com/jorge-nexsys/anam
7
+ Project-URL: Documentation, https://jorge-nexsys.github.io/anam
8
+ Project-URL: Issues, https://github.com/jorge-nexsys/anam/issues
9
+ Author: Jorge Martinez
10
+ License-Expression: Apache-2.0
11
+ Keywords: ai,arrow,database,datalog,neurosymbolic
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Database
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Provides-Extra: arrow
25
+ Requires-Dist: pyarrow>=14.0; extra == 'arrow'
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # AnamDB Python SDK
32
+
33
+ Python client for [AnamDB](https://github.com/jorge-nexsys/anam) — the AI-native neurosymbolic database engine.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install anamdb
39
+ ```
40
+
41
+ For Arrow IPC support (decode query results into PyArrow tables):
42
+
43
+ ```bash
44
+ pip install anamdb[arrow]
45
+ ```
46
+
47
+ ## Quick Start
48
+
49
+ ```python
50
+ import asyncio
51
+ from anamdb import AnamClient
52
+
53
+ async def main():
54
+ # Connect to a running AnamDB server
55
+ async with AnamClient("127.0.0.1:8080") as client:
56
+ # Check server health
57
+ health = await client.health()
58
+ print(f"Server: {health.status} (v{health.version})")
59
+
60
+ # Register a table
61
+ await client.register_table("txns", "/data/transactions.lance")
62
+
63
+ # Register a Datalog rule
64
+ await client.register_rule("high_risk", "fraud_prob > 0.90 AND amount > 10000")
65
+
66
+ # Run a SQL query
67
+ result = await client.query(
68
+ "SELECT region, COUNT(1) AS count "
69
+ "FROM txns WHERE fraud_prob > 0.90 "
70
+ "GROUP BY region ORDER BY count DESC"
71
+ )
72
+
73
+ print(f"Rows: {result.row_count}")
74
+ if result.reasoning_tree:
75
+ print(f"Reasoning: {result.reasoning_tree}")
76
+
77
+ asyncio.run(main())
78
+ ```
79
+
80
+ ## API Reference
81
+
82
+ ### `AnamClient(addr, *, connect_timeout=5.0, max_retries=3)`
83
+
84
+ Async context manager for connecting to an AnamDB server.
85
+
86
+ **Methods:**
87
+
88
+ | Method | Description |
89
+ |:---|:---|
90
+ | `query(sql)` | Execute a SQL query, returns `QueryResult` |
91
+ | `register_table(name, lance_path)` | Register a Lance dataset as a table |
92
+ | `register_rule(name, datalog)` | Register a Datalog rule |
93
+ | `load_model(name, version, path, ...)` | Load an ONNX model |
94
+ | `health()` | Server health check |
95
+
96
+ ### `QueryResult`
97
+
98
+ | Field | Type | Description |
99
+ |:---|:---|:---|
100
+ | `row_count` | `int` | Number of rows returned |
101
+ | `reasoning_tree` | `str \| None` | Provenance reasoning trace |
102
+ | `anomalies` | `list[str]` | Semantic anomaly descriptions |
103
+ | `raw_response` | `dict` | Full JSON response from server |
104
+
105
+ ### `ServerHealth`
106
+
107
+ | Field | Type | Description |
108
+ |:---|:---|:---|
109
+ | `status` | `str` | `"SERVING"` or `"NOT_SERVING"` |
110
+ | `version` | `str` | AnamDB server version |
111
+ | `table_count` | `int` | Number of registered tables |
112
+ | `model_count` | `int` | Number of loaded models |
113
+ | `rule_count` | `int` | Number of Datalog rules |
114
+
115
+ ## License
116
+
117
+ Apache License 2.0
anamdb-1.0.0/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # AnamDB Python SDK
2
+
3
+ Python client for [AnamDB](https://github.com/jorge-nexsys/anam) — the AI-native neurosymbolic database engine.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install anamdb
9
+ ```
10
+
11
+ For Arrow IPC support (decode query results into PyArrow tables):
12
+
13
+ ```bash
14
+ pip install anamdb[arrow]
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ import asyncio
21
+ from anamdb import AnamClient
22
+
23
+ async def main():
24
+ # Connect to a running AnamDB server
25
+ async with AnamClient("127.0.0.1:8080") as client:
26
+ # Check server health
27
+ health = await client.health()
28
+ print(f"Server: {health.status} (v{health.version})")
29
+
30
+ # Register a table
31
+ await client.register_table("txns", "/data/transactions.lance")
32
+
33
+ # Register a Datalog rule
34
+ await client.register_rule("high_risk", "fraud_prob > 0.90 AND amount > 10000")
35
+
36
+ # Run a SQL query
37
+ result = await client.query(
38
+ "SELECT region, COUNT(1) AS count "
39
+ "FROM txns WHERE fraud_prob > 0.90 "
40
+ "GROUP BY region ORDER BY count DESC"
41
+ )
42
+
43
+ print(f"Rows: {result.row_count}")
44
+ if result.reasoning_tree:
45
+ print(f"Reasoning: {result.reasoning_tree}")
46
+
47
+ asyncio.run(main())
48
+ ```
49
+
50
+ ## API Reference
51
+
52
+ ### `AnamClient(addr, *, connect_timeout=5.0, max_retries=3)`
53
+
54
+ Async context manager for connecting to an AnamDB server.
55
+
56
+ **Methods:**
57
+
58
+ | Method | Description |
59
+ |:---|:---|
60
+ | `query(sql)` | Execute a SQL query, returns `QueryResult` |
61
+ | `register_table(name, lance_path)` | Register a Lance dataset as a table |
62
+ | `register_rule(name, datalog)` | Register a Datalog rule |
63
+ | `load_model(name, version, path, ...)` | Load an ONNX model |
64
+ | `health()` | Server health check |
65
+
66
+ ### `QueryResult`
67
+
68
+ | Field | Type | Description |
69
+ |:---|:---|:---|
70
+ | `row_count` | `int` | Number of rows returned |
71
+ | `reasoning_tree` | `str \| None` | Provenance reasoning trace |
72
+ | `anomalies` | `list[str]` | Semantic anomaly descriptions |
73
+ | `raw_response` | `dict` | Full JSON response from server |
74
+
75
+ ### `ServerHealth`
76
+
77
+ | Field | Type | Description |
78
+ |:---|:---|:---|
79
+ | `status` | `str` | `"SERVING"` or `"NOT_SERVING"` |
80
+ | `version` | `str` | AnamDB server version |
81
+ | `table_count` | `int` | Number of registered tables |
82
+ | `model_count` | `int` | Number of loaded models |
83
+ | `rule_count` | `int` | Number of Datalog rules |
84
+
85
+ ## License
86
+
87
+ Apache License 2.0
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "anamdb"
7
+ version = "1.0.0"
8
+ description = "Python client for AnamDB — the AI-native neurosymbolic database engine."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Jorge Martinez" },
14
+ ]
15
+ keywords = ["database", "ai", "neurosymbolic", "datalog", "arrow"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Database",
26
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
+ "Typing :: Typed",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ arrow = ["pyarrow>=14.0"]
32
+ dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://jorge-nexsys.github.io/anam"
36
+ Repository = "https://github.com/jorge-nexsys/anam"
37
+ Documentation = "https://jorge-nexsys.github.io/anam"
38
+ Issues = "https://github.com/jorge-nexsys/anam/issues"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/anamdb"]
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
@@ -0,0 +1,31 @@
1
+ """AnamDB Python SDK — async client for the AnamDB neurosymbolic database engine."""
2
+
3
+ from anamdb.client import AnamClient
4
+ from anamdb.exceptions import (
5
+ AnamDBError,
6
+ ConnectionError,
7
+ QueryError,
8
+ ProtocolError,
9
+ )
10
+ from anamdb.models import (
11
+ QueryResult,
12
+ ServerHealth,
13
+ TableResponse,
14
+ RuleResponse,
15
+ ModelResponse,
16
+ )
17
+
18
+ __version__ = "1.0.0"
19
+
20
+ __all__ = [
21
+ "AnamClient",
22
+ "AnamDBError",
23
+ "ConnectionError",
24
+ "QueryError",
25
+ "ProtocolError",
26
+ "QueryResult",
27
+ "ServerHealth",
28
+ "TableResponse",
29
+ "RuleResponse",
30
+ "ModelResponse",
31
+ ]
@@ -0,0 +1,264 @@
1
+ """Async client for connecting to a running AnamDB server.
2
+
3
+ Uses the JSON-over-TCP wire protocol defined in the AnamDB server module.
4
+ Each command is a single JSON line; each response is a single JSON line back.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ from typing import Any
13
+
14
+ from anamdb.exceptions import (
15
+ AnamDBError,
16
+ ConnectionError,
17
+ ProtocolError,
18
+ QueryError,
19
+ )
20
+ from anamdb.models import (
21
+ ModelResponse,
22
+ QueryResult,
23
+ RuleResponse,
24
+ ServerHealth,
25
+ TableResponse,
26
+ )
27
+
28
+ logger = logging.getLogger("anamdb")
29
+
30
+
31
+ class AnamClient:
32
+ """Async client for the AnamDB neurosymbolic database engine.
33
+
34
+ Connects to a running AnamDB server over TCP and communicates using
35
+ the JSON-over-TCP wire protocol.
36
+
37
+ Use as an async context manager::
38
+
39
+ async with AnamClient("127.0.0.1:8080") as client:
40
+ result = await client.query("SELECT * FROM txns LIMIT 10")
41
+
42
+ Or manage the connection manually::
43
+
44
+ client = AnamClient("127.0.0.1:8080")
45
+ await client.connect()
46
+ result = await client.query("SELECT * FROM txns LIMIT 10")
47
+ await client.close()
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ addr: str = "127.0.0.1:8080",
53
+ *,
54
+ connect_timeout: float = 5.0,
55
+ max_retries: int = 3,
56
+ ) -> None:
57
+ self._addr = addr
58
+ self._connect_timeout = connect_timeout
59
+ self._max_retries = max_retries
60
+
61
+ self._reader: asyncio.StreamReader | None = None
62
+ self._writer: asyncio.StreamWriter | None = None
63
+
64
+ # ── Connection lifecycle ──────────────────────────────────────────
65
+
66
+ async def connect(self) -> None:
67
+ """Establish the TCP connection to the AnamDB server."""
68
+ if self._writer is not None:
69
+ return # Already connected.
70
+
71
+ host, _, port_str = self._addr.rpartition(":")
72
+ if not host:
73
+ host = "127.0.0.1"
74
+ port = int(port_str) if port_str else 8080
75
+
76
+ try:
77
+ self._reader, self._writer = await asyncio.wait_for(
78
+ asyncio.open_connection(host, port),
79
+ timeout=self._connect_timeout,
80
+ )
81
+ logger.info("Connected to AnamDB at %s", self._addr)
82
+ except asyncio.TimeoutError as exc:
83
+ raise ConnectionError(
84
+ f"Connection to {self._addr} timed out after {self._connect_timeout}s"
85
+ ) from exc
86
+ except OSError as exc:
87
+ raise ConnectionError(
88
+ f"Failed to connect to {self._addr}: {exc}"
89
+ ) from exc
90
+
91
+ async def close(self) -> None:
92
+ """Close the TCP connection."""
93
+ if self._writer is not None:
94
+ self._writer.close()
95
+ try:
96
+ await self._writer.wait_closed()
97
+ except Exception:
98
+ pass # Best effort.
99
+ self._writer = None
100
+ self._reader = None
101
+ logger.info("Disconnected from AnamDB")
102
+
103
+ async def __aenter__(self) -> "AnamClient":
104
+ await self.connect()
105
+ return self
106
+
107
+ async def __aexit__(self, *exc: Any) -> None:
108
+ await self.close()
109
+
110
+ # ── Wire protocol ────────────────────────────────────────────────
111
+
112
+ async def _send_command(self, cmd: dict) -> dict:
113
+ """Send a JSON command and receive the JSON response."""
114
+ if self._reader is None or self._writer is None:
115
+ raise ConnectionError("Not connected — call connect() first")
116
+
117
+ payload = json.dumps(cmd, separators=(",", ":")) + "\n"
118
+ self._writer.write(payload.encode())
119
+ await self._writer.drain()
120
+
121
+ line = await self._reader.readline()
122
+ if not line:
123
+ raise ConnectionError("Server closed the connection")
124
+
125
+ try:
126
+ return json.loads(line.decode())
127
+ except json.JSONDecodeError as exc:
128
+ raise ProtocolError(f"Invalid JSON response: {exc}") from exc
129
+
130
+ async def _send_with_retry(self, cmd: dict) -> dict:
131
+ """Send a command with automatic retry on transient failures."""
132
+ last_exc: Exception | None = None
133
+ for attempt in range(1, self._max_retries + 1):
134
+ try:
135
+ return await self._send_command(cmd)
136
+ except ConnectionError as exc:
137
+ last_exc = exc
138
+ logger.warning(
139
+ "Attempt %d/%d failed: %s",
140
+ attempt,
141
+ self._max_retries,
142
+ exc,
143
+ )
144
+ # Try to reconnect.
145
+ await self.close()
146
+ try:
147
+ await self.connect()
148
+ except ConnectionError:
149
+ pass
150
+ raise last_exc or ConnectionError("All retry attempts failed")
151
+
152
+ # ── Public API ───────────────────────────────────────────────────
153
+
154
+ async def query(self, sql: str) -> QueryResult:
155
+ """Execute a SQL query on the AnamDB server.
156
+
157
+ Args:
158
+ sql: The SQL query string.
159
+
160
+ Returns:
161
+ A :class:`QueryResult` with row count, reasoning tree, and anomalies.
162
+
163
+ Raises:
164
+ QueryError: If the server reports a query execution error.
165
+ """
166
+ resp = await self._send_with_retry({"method": "query", "sql": sql})
167
+
168
+ if not resp.get("ok", False):
169
+ raise QueryError(
170
+ resp.get("error", "unknown server error"),
171
+ sql=sql,
172
+ )
173
+
174
+ return QueryResult(
175
+ row_count=resp.get("ipc_bytes", 0),
176
+ reasoning_tree=resp.get("reasoning_tree") or None,
177
+ anomalies=resp.get("anomalies", []),
178
+ raw_response=resp,
179
+ )
180
+
181
+ async def register_table(self, name: str, lance_path: str) -> TableResponse:
182
+ """Register a Lance dataset as a queryable table.
183
+
184
+ Args:
185
+ name: Logical table name.
186
+ lance_path: Filesystem path to the Lance dataset.
187
+ """
188
+ resp = await self._send_with_retry(
189
+ {"method": "register_table", "name": name, "lance_path": lance_path}
190
+ )
191
+ return TableResponse(
192
+ success=resp.get("ok", False),
193
+ message=resp.get("message", ""),
194
+ )
195
+
196
+ async def register_rule(self, name: str, datalog: str) -> RuleResponse:
197
+ """Register a Datalog rule as a query filter.
198
+
199
+ Args:
200
+ name: Rule name.
201
+ datalog: Datalog expression (e.g. ``"fraud_prob > 0.90 AND amount > 10000"``).
202
+ """
203
+ resp = await self._send_with_retry(
204
+ {"method": "register_rule", "name": name, "datalog": datalog}
205
+ )
206
+ return RuleResponse(
207
+ success=resp.get("ok", False),
208
+ message=resp.get("message", ""),
209
+ )
210
+
211
+ async def load_model(
212
+ self,
213
+ name: str,
214
+ version: str,
215
+ model_path: str,
216
+ function_id: str,
217
+ *,
218
+ num_features: int = 3,
219
+ avg_latency_ms: float = 1.0,
220
+ accuracy: float = 0.95,
221
+ ) -> ModelResponse:
222
+ """Load an ONNX model into the AnamDB model registry.
223
+
224
+ Args:
225
+ name: Model name (becomes the SQL function name).
226
+ version: Model version string.
227
+ model_path: Path to the ONNX model file.
228
+ function_id: SQL function identifier.
229
+ num_features: Number of input features.
230
+ avg_latency_ms: Expected average latency in milliseconds.
231
+ accuracy: Expected model accuracy (0.0–1.0).
232
+ """
233
+ resp = await self._send_with_retry(
234
+ {
235
+ "method": "load_model",
236
+ "name": name,
237
+ "version": version,
238
+ "model_path": model_path,
239
+ "function_id": function_id,
240
+ "num_features": num_features,
241
+ "avg_latency_ms": avg_latency_ms,
242
+ "accuracy": accuracy,
243
+ }
244
+ )
245
+ return ModelResponse(
246
+ success=resp.get("ok", False),
247
+ model_id=resp.get("model_id", ""),
248
+ message=resp.get("message", ""),
249
+ )
250
+
251
+ async def health(self) -> ServerHealth:
252
+ """Check the health of the AnamDB server.
253
+
254
+ Returns:
255
+ A :class:`ServerHealth` with server status and resource counts.
256
+ """
257
+ resp = await self._send_with_retry({"method": "health"})
258
+ return ServerHealth(
259
+ status=resp.get("status", "UNKNOWN"),
260
+ version=resp.get("version", "?"),
261
+ table_count=resp.get("tables", 0),
262
+ model_count=resp.get("models", 0),
263
+ rule_count=resp.get("rules", 0),
264
+ )
@@ -0,0 +1,25 @@
1
+ """Exception hierarchy for the AnamDB Python SDK."""
2
+
3
+
4
+ class AnamDBError(Exception):
5
+ """Base exception for all AnamDB client errors."""
6
+
7
+
8
+ class ConnectionError(AnamDBError):
9
+ """Raised when the client cannot connect to the AnamDB server."""
10
+
11
+
12
+ class QueryError(AnamDBError):
13
+ """Raised when a SQL query fails on the server."""
14
+
15
+ def __init__(self, message: str, sql: str | None = None):
16
+ self.sql = sql
17
+ super().__init__(message)
18
+
19
+
20
+ class ProtocolError(AnamDBError):
21
+ """Raised when the server sends an invalid or unexpected response."""
22
+
23
+
24
+ class TimeoutError(AnamDBError):
25
+ """Raised when an operation exceeds the configured timeout."""
@@ -0,0 +1,67 @@
1
+ """Data models for AnamDB responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class QueryResult:
10
+ """Result of a SQL query execution."""
11
+
12
+ row_count: int
13
+ """Number of rows returned (from server-reported ipc_bytes, or 0)."""
14
+
15
+ reasoning_tree: str | None = None
16
+ """Provenance reasoning trace (if provenance mode is enabled)."""
17
+
18
+ anomalies: list[str] = field(default_factory=list)
19
+ """Semantic anomaly descriptions detected during execution."""
20
+
21
+ raw_response: dict = field(default_factory=dict, repr=False)
22
+ """Full JSON response from the server."""
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ServerHealth:
27
+ """Server health status."""
28
+
29
+ status: str
30
+ """``'SERVING'`` or ``'NOT_SERVING'``."""
31
+
32
+ version: str
33
+ """AnamDB server version string."""
34
+
35
+ table_count: int = 0
36
+ """Number of registered tables."""
37
+
38
+ model_count: int = 0
39
+ """Number of loaded ONNX models."""
40
+
41
+ rule_count: int = 0
42
+ """Number of active Datalog rules."""
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class TableResponse:
47
+ """Response from a table registration request."""
48
+
49
+ success: bool
50
+ message: str
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class RuleResponse:
55
+ """Response from a Datalog rule registration request."""
56
+
57
+ success: bool
58
+ message: str
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class ModelResponse:
63
+ """Response from a model loading request."""
64
+
65
+ success: bool
66
+ model_id: str
67
+ message: str
@@ -0,0 +1,156 @@
1
+ """Tests for the AnamDB Python client.
2
+
3
+ These tests mock the TCP connection to verify protocol encoding/decoding
4
+ without requiring a running AnamDB server.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+
12
+ import pytest
13
+
14
+ from anamdb.client import AnamClient
15
+ from anamdb.exceptions import ConnectionError, QueryError
16
+ from anamdb.models import QueryResult, ServerHealth
17
+
18
+
19
+ # ── Helpers ──────────────────────────────────────────────────────────
20
+
21
+
22
+ async def _make_mock_server(
23
+ responses: list[dict],
24
+ host: str = "127.0.0.1",
25
+ port: int = 0,
26
+ ) -> tuple[asyncio.Server, int]:
27
+ """Start a mock TCP server that returns pre-canned JSON responses."""
28
+ response_iter = iter(responses)
29
+
30
+ async def handler(
31
+ reader: asyncio.StreamReader,
32
+ writer: asyncio.StreamWriter,
33
+ ) -> None:
34
+ while True:
35
+ line = await reader.readline()
36
+ if not line:
37
+ break
38
+ resp = next(response_iter, {"ok": False, "error": "no more responses"})
39
+ payload = json.dumps(resp) + "\n"
40
+ writer.write(payload.encode())
41
+ await writer.drain()
42
+ writer.close()
43
+
44
+ server = await asyncio.start_server(handler, host, port)
45
+ actual_port = server.sockets[0].getsockname()[1]
46
+ return server, actual_port
47
+
48
+
49
+ # ── Tests ────────────────────────────────────────────────────────────
50
+
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_health() -> None:
54
+ server, port = await _make_mock_server(
55
+ [
56
+ {
57
+ "status": "SERVING",
58
+ "version": "1.0.0",
59
+ "tables": 3,
60
+ "models": 2,
61
+ "rules": 5,
62
+ }
63
+ ]
64
+ )
65
+ async with server:
66
+ async with AnamClient(f"127.0.0.1:{port}") as client:
67
+ health = await client.health()
68
+
69
+ assert isinstance(health, ServerHealth)
70
+ assert health.status == "SERVING"
71
+ assert health.version == "1.0.0"
72
+ assert health.table_count == 3
73
+ assert health.model_count == 2
74
+ assert health.rule_count == 5
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_query_success() -> None:
79
+ server, port = await _make_mock_server(
80
+ [
81
+ {
82
+ "ok": True,
83
+ "ipc_bytes": 1024,
84
+ "reasoning_tree": "high_risk <- fraud_prob > 0.90",
85
+ "anomalies": [],
86
+ }
87
+ ]
88
+ )
89
+ async with server:
90
+ async with AnamClient(f"127.0.0.1:{port}") as client:
91
+ result = await client.query("SELECT * FROM txns LIMIT 10")
92
+
93
+ assert isinstance(result, QueryResult)
94
+ assert result.row_count == 1024
95
+ assert result.reasoning_tree == "high_risk <- fraud_prob > 0.90"
96
+ assert result.anomalies == []
97
+
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_query_error() -> None:
101
+ server, port = await _make_mock_server(
102
+ [{"ok": False, "error": "table 'missing' not found"}]
103
+ )
104
+ async with server:
105
+ async with AnamClient(f"127.0.0.1:{port}") as client:
106
+ with pytest.raises(QueryError, match="table 'missing' not found"):
107
+ await client.query("SELECT * FROM missing")
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_register_table() -> None:
112
+ server, port = await _make_mock_server(
113
+ [{"ok": True, "message": "table 'txns' registered"}]
114
+ )
115
+ async with server:
116
+ async with AnamClient(f"127.0.0.1:{port}") as client:
117
+ resp = await client.register_table("txns", "/data/txns.lance")
118
+
119
+ assert resp.success is True
120
+ assert "txns" in resp.message
121
+
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_register_rule() -> None:
125
+ server, port = await _make_mock_server(
126
+ [{"ok": True, "message": "rule 'high_risk' registered"}]
127
+ )
128
+ async with server:
129
+ async with AnamClient(f"127.0.0.1:{port}") as client:
130
+ resp = await client.register_rule(
131
+ "high_risk", "fraud_prob > 0.90 AND amount > 10000"
132
+ )
133
+
134
+ assert resp.success is True
135
+ assert "high_risk" in resp.message
136
+
137
+
138
+ @pytest.mark.asyncio
139
+ async def test_connection_refused() -> None:
140
+ client = AnamClient("127.0.0.1:19999", connect_timeout=0.5)
141
+ with pytest.raises(ConnectionError):
142
+ await client.connect()
143
+
144
+
145
+ @pytest.mark.asyncio
146
+ async def test_context_manager_auto_close() -> None:
147
+ server, port = await _make_mock_server(
148
+ [{"status": "SERVING", "version": "1.0.0", "tables": 0, "models": 0, "rules": 0}]
149
+ )
150
+ async with server:
151
+ async with AnamClient(f"127.0.0.1:{port}") as client:
152
+ assert client._writer is not None
153
+ await client.health()
154
+
155
+ # After exiting, writer should be None.
156
+ assert client._writer is None