tetrix-sdk 0.1.1__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,62 @@
1
+ """TetrixAIDb Python SDK — AI-native database client."""
2
+
3
+ __version__ = "0.1.1"
4
+
5
+ from tetrix_aidb.client import TetrixClient, create_client
6
+ from tetrix_aidb.errors import TetrixError
7
+ from tetrix_aidb.types import (
8
+ Category,
9
+ ClientConfig,
10
+ DeleteBatchResponse,
11
+ Entity,
12
+ EntityContent,
13
+ HealthResponse,
14
+ IndexBatchItem,
15
+ IndexBatchOptions,
16
+ IndexBatchResponse,
17
+ IndexBatchResultItem,
18
+ ListCategoriesResponse,
19
+ ListEntityCategoriesResponse,
20
+ ListOrgsResponse,
21
+ ListPermissionsResponse,
22
+ ListTeamMembersResponse,
23
+ ListTeamsResponse,
24
+ ListUsersResponse,
25
+ MetricsResponse,
26
+ Organization,
27
+ Permission,
28
+ RelationRef,
29
+ Team,
30
+ TeamMember,
31
+ User,
32
+ )
33
+
34
+ __all__ = [
35
+ "TetrixClient",
36
+ "create_client",
37
+ "Category",
38
+ "ClientConfig",
39
+ "DeleteBatchResponse",
40
+ "Entity",
41
+ "EntityContent",
42
+ "HealthResponse",
43
+ "IndexBatchItem",
44
+ "IndexBatchOptions",
45
+ "IndexBatchResponse",
46
+ "IndexBatchResultItem",
47
+ "ListCategoriesResponse",
48
+ "ListEntityCategoriesResponse",
49
+ "ListOrgsResponse",
50
+ "ListPermissionsResponse",
51
+ "ListTeamMembersResponse",
52
+ "ListTeamsResponse",
53
+ "ListUsersResponse",
54
+ "MetricsResponse",
55
+ "Organization",
56
+ "Permission",
57
+ "RelationRef",
58
+ "Team",
59
+ "TeamMember",
60
+ "TetrixError",
61
+ "User",
62
+ ]
tetrix_aidb/client.py ADDED
@@ -0,0 +1,767 @@
1
+ """TetrixAIDb async client implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import uuid
7
+ from typing import Any
8
+ from urllib.parse import urlparse
9
+
10
+ from tetrix_aidb.errors import ConnectionError, TetrixError
11
+ from tetrix_aidb.protocol import encode_frame, read_frame
12
+ from tetrix_aidb.types import (
13
+ Category,
14
+ ClientConfig,
15
+ DeleteBatchResponse,
16
+ DeleteRequest,
17
+ DeleteResponse,
18
+ EmbedBatchRequest,
19
+ EmbedBatchResponse,
20
+ EntityContent,
21
+ GetRequest,
22
+ GetResponse,
23
+ HealthResponse,
24
+ IndexBatchItem,
25
+ IndexBatchOptions,
26
+ IndexBatchResponse,
27
+ IndexBatchResultItem,
28
+ IndexRequest,
29
+ IndexResponse,
30
+ ListCategoriesResponse,
31
+ ListEntityCategoriesResponse,
32
+ ListOrgsResponse,
33
+ ListPermissionsResponse,
34
+ ListTeamMembersResponse,
35
+ ListTeamsResponse,
36
+ ListUsersResponse,
37
+ MetricsResponse,
38
+ Organization,
39
+ Permission,
40
+ SemanticSearchRequest,
41
+ SemanticSearchResponse,
42
+ Team,
43
+ TeamMember,
44
+ UpdateRequest,
45
+ UpdateResponse,
46
+ User,
47
+ )
48
+
49
+ PROTOCOL_VERSION = "1.0"
50
+
51
+
52
+ class TetrixClient:
53
+ """Async client for TetrixAIDb."""
54
+
55
+ def __init__(self, config: ClientConfig) -> None:
56
+ self._config = config
57
+ self._reader: asyncio.StreamReader | None = None
58
+ self._writer: asyncio.StreamWriter | None = None
59
+ self._lock = asyncio.Lock()
60
+ self._authenticated = False
61
+
62
+ async def connect(self) -> None:
63
+ """Establish connection and perform AUTH handshake."""
64
+ parsed = urlparse(self._config.endpoint)
65
+
66
+ try:
67
+ if parsed.scheme == "unix":
68
+ self._reader, self._writer = await asyncio.open_unix_connection(parsed.path)
69
+ else:
70
+ host = parsed.hostname or "localhost"
71
+ port = parsed.port or 7779
72
+ self._reader, self._writer = await asyncio.open_connection(host, port)
73
+ except OSError as e:
74
+ raise ConnectionError(f"failed to connect to {self._config.endpoint}: {e}") from e
75
+
76
+ await self._authenticate()
77
+
78
+ async def _authenticate(self) -> None:
79
+ """Send AUTH handshake and validate response."""
80
+ auth_frame: dict[str, Any] = {
81
+ "protocolVersion": PROTOCOL_VERSION,
82
+ "op": "AUTH",
83
+ "username": self._config.username,
84
+ "password": self._config.password,
85
+ }
86
+
87
+ assert self._writer is not None
88
+ assert self._reader is not None
89
+
90
+ self._writer.write(encode_frame(auth_frame))
91
+ await self._writer.drain()
92
+ resp = await read_frame(self._reader)
93
+
94
+ if resp.get("status") != "ok":
95
+ err = resp.get("error", {})
96
+ await self.close()
97
+ raise TetrixError(
98
+ code=err.get("code", "UNAUTHENTICATED"),
99
+ message=err.get("message", "authentication failed"),
100
+ retryable=False,
101
+ )
102
+
103
+ self._authenticated = True
104
+
105
+ async def close(self) -> None:
106
+ """Close the connection."""
107
+ if self._writer:
108
+ self._writer.close()
109
+ await self._writer.wait_closed()
110
+ self._writer = None
111
+ self._reader = None
112
+ self._authenticated = False
113
+
114
+ async def _request(self, op: str, payload: dict[str, Any]) -> dict[str, Any]:
115
+ """Send a request and return the response payload."""
116
+ if not self._writer or not self._reader:
117
+ await self.connect()
118
+
119
+ envelope = {
120
+ "protocolVersion": PROTOCOL_VERSION,
121
+ "requestId": str(uuid.uuid4()),
122
+ "op": op,
123
+ "payload": payload,
124
+ }
125
+
126
+ async with self._lock:
127
+ self._writer.write(encode_frame(envelope))
128
+ await self._writer.drain()
129
+ resp = await read_frame(self._reader)
130
+
131
+ if resp.get("status") == "error":
132
+ err = resp.get("error", {})
133
+ raise TetrixError(
134
+ code=err.get("code", "UNKNOWN"),
135
+ message=err.get("message", "unknown error"),
136
+ retryable=err.get("retryable", False),
137
+ details=err.get("details"),
138
+ )
139
+
140
+ return resp.get("payload", {})
141
+
142
+ # =================================================================
143
+ # Data Operations
144
+ # =================================================================
145
+
146
+ async def index(self, request: IndexRequest) -> IndexResponse:
147
+ """Create or index an entity."""
148
+ payload = _index_request_to_dict(request)
149
+ resp = await self._request("INDEX", payload)
150
+ return IndexResponse(
151
+ id=resp["id"],
152
+ version=resp["version"],
153
+ created_at=resp["createdAt"],
154
+ write_consistency=resp.get("writeConsistency", "strong"),
155
+ )
156
+
157
+ async def get(self, request: GetRequest) -> GetResponse:
158
+ """Retrieve an entity by ID."""
159
+ payload = {
160
+ "id": request.id,
161
+ "includeContent": request.include_content,
162
+ "includeRelations": request.include_relations,
163
+ }
164
+ resp = await self._request("GET", payload)
165
+ content = None
166
+ if resp.get("content"):
167
+ c = resp["content"]
168
+ content = EntityContent(
169
+ text=c.get("text", ""),
170
+ raw=c.get("raw"),
171
+ encoding=c.get("encoding"),
172
+ )
173
+ return GetResponse(entity=_dict_to_entity(resp.get("entity", {})), content=content)
174
+
175
+ async def update(self, request: UpdateRequest) -> UpdateResponse:
176
+ """Update an entity."""
177
+ payload: dict[str, Any] = {"id": request.id}
178
+ if request.expected_version:
179
+ payload["expectedVersion"] = request.expected_version
180
+ if request.patch:
181
+ patch: dict[str, Any] = {}
182
+ if request.patch.name:
183
+ patch["name"] = request.patch.name
184
+ if request.patch.metadata is not None:
185
+ patch["metadata"] = request.patch.metadata
186
+ if request.patch.relations is not None:
187
+ patch["relations"] = [
188
+ {"type": r.type, "targetId": r.target_id}
189
+ for r in request.patch.relations
190
+ ]
191
+ payload["patch"] = patch
192
+ if request.content_patch:
193
+ cp: dict[str, Any] = {}
194
+ if request.content_patch.text is not None:
195
+ cp["text"] = request.content_patch.text
196
+ if request.content_patch.raw is not None:
197
+ cp["raw"] = request.content_patch.raw
198
+ if request.content_patch.encoding is not None:
199
+ cp["encoding"] = request.content_patch.encoding
200
+ payload["contentPatch"] = cp
201
+ resp = await self._request("UPDATE", payload)
202
+ return UpdateResponse(
203
+ id=resp["id"],
204
+ version=resp["version"],
205
+ updated_at=resp["updatedAt"],
206
+ )
207
+
208
+ async def delete(self, request: DeleteRequest) -> DeleteResponse:
209
+ """Delete an entity."""
210
+ payload: dict[str, Any] = {"id": request.id, "deleteMode": request.delete_mode}
211
+ if request.expected_version:
212
+ payload["expectedVersion"] = request.expected_version
213
+ resp = await self._request("DELETE", payload)
214
+ return DeleteResponse(
215
+ id=resp["id"],
216
+ status=resp["status"],
217
+ deleted_at=resp["deletedAt"],
218
+ )
219
+
220
+ async def semantic_search(self, request: SemanticSearchRequest) -> SemanticSearchResponse:
221
+ """Search for entities by meaning."""
222
+ payload: dict[str, Any] = {"text": request.text}
223
+ if request.filters:
224
+ filters: dict[str, Any] = {}
225
+ if request.filters.type:
226
+ filters["type"] = request.filters.type
227
+ if request.filters.category:
228
+ filters["category"] = request.filters.category
229
+ if request.filters.owner:
230
+ filters["owner"] = request.filters.owner
231
+ if request.filters.metadata:
232
+ filters["metadata"] = request.filters.metadata
233
+ payload["filters"] = filters
234
+ if request.options:
235
+ payload["options"] = {
236
+ "topK": request.options.top_k,
237
+ "offset": request.options.offset,
238
+ "expandGraph": request.options.expand_graph,
239
+ "graphHops": request.options.graph_hops,
240
+ "includeContentPreview": request.options.include_content_preview,
241
+ "includeRelations": request.options.include_relations,
242
+ "useKeyword": request.options.use_keyword,
243
+ }
244
+ resp = await self._request("SEMANTIC_SEARCH", payload)
245
+ items = [_dict_to_search_result(item) for item in resp.get("items", [])]
246
+ return SemanticSearchResponse(
247
+ items=items,
248
+ total=resp.get("total", 0),
249
+ next_offset=resp.get("nextOffset"),
250
+ )
251
+
252
+ async def index_batch(
253
+ self,
254
+ items: list[IndexBatchItem],
255
+ options: IndexBatchOptions | None = None,
256
+ ) -> IndexBatchResponse:
257
+ """Index multiple entities in a single batch."""
258
+ batch_items = []
259
+ for item in items:
260
+ entry: dict[str, Any] = {
261
+ "entity": {
262
+ "type": item.entity.type,
263
+ "owner": item.entity.owner,
264
+ "name": item.entity.name,
265
+ },
266
+ "content": {"text": item.content.text},
267
+ }
268
+ if item.entity.id:
269
+ entry["entity"]["id"] = item.entity.id
270
+ if item.entity.metadata:
271
+ entry["entity"]["metadata"] = item.entity.metadata
272
+ if item.entity.relations:
273
+ entry["entity"]["relations"] = [
274
+ {"type": r.type, "targetId": r.target_id}
275
+ for r in item.entity.relations
276
+ ]
277
+ if item.content.raw:
278
+ entry["content"]["raw"] = item.content.raw
279
+ if item.content.encoding:
280
+ entry["content"]["encoding"] = item.content.encoding
281
+ if item.category_ids:
282
+ entry["categoryIds"] = item.category_ids
283
+ batch_items.append(entry)
284
+
285
+ payload: dict[str, Any] = {"items": batch_items}
286
+ if options:
287
+ payload["options"] = {"writeConsistency": options.write_consistency}
288
+
289
+ resp = await self._request("INDEX_BATCH", payload)
290
+ results = [
291
+ IndexBatchResultItem(
292
+ id=r.get("id", ""),
293
+ version=r.get("version", ""),
294
+ error=r.get("error"),
295
+ )
296
+ for r in resp.get("results", [])
297
+ ]
298
+ return IndexBatchResponse(
299
+ total_count=resp.get("totalCount", 0),
300
+ failed_count=resp.get("failedCount", 0),
301
+ results=results,
302
+ )
303
+
304
+ async def delete_batch(self, ids: list[str], delete_mode: str = "soft") -> DeleteBatchResponse:
305
+ """Delete multiple entities in a single batch."""
306
+ payload: dict[str, Any] = {"ids": ids, "deleteMode": delete_mode}
307
+ resp = await self._request("DELETE_BATCH", payload)
308
+ return DeleteBatchResponse(
309
+ total_count=resp.get("totalCount", 0),
310
+ failed_count=resp.get("failedCount", 0),
311
+ )
312
+
313
+ async def list_entity_events(
314
+ self,
315
+ entity_id: str,
316
+ event_types: list[str] | None = None,
317
+ limit: int = 50,
318
+ offset: int = 0,
319
+ ) -> dict:
320
+ """List events for an entity. Returns {events: [...], total: int}."""
321
+ payload: dict[str, Any] = {"entityId": entity_id, "limit": limit, "offset": offset}
322
+ if event_types:
323
+ payload["eventTypes"] = event_types
324
+ return await self._request("LIST_ENTITY_EVENTS", payload)
325
+
326
+ async def embed_batch(self, request: EmbedBatchRequest) -> EmbedBatchResponse:
327
+ """Generate embedding vectors for multiple texts via the daemon's embedding provider."""
328
+ payload: dict[str, Any] = {"texts": request.texts}
329
+ resp = await self._request("EMBED_BATCH", payload)
330
+ return EmbedBatchResponse(
331
+ embeddings=resp.get("embeddings", []),
332
+ dimensions=resp.get("dimensions", 0),
333
+ model=resp.get("model", ""),
334
+ cached=resp.get("cached", 0),
335
+ )
336
+
337
+ async def health(self) -> HealthResponse:
338
+ """Check daemon health."""
339
+ resp = await self._request("HEALTH", {})
340
+ return HealthResponse(
341
+ status=resp.get("status", "unknown"),
342
+ uptime_seconds=resp.get("uptimeSeconds", 0),
343
+ backends=resp.get("backends", {}),
344
+ )
345
+
346
+ async def metrics(self) -> MetricsResponse:
347
+ """Get daemon metrics."""
348
+ resp = await self._request("METRICS", {})
349
+ return MetricsResponse(data=resp)
350
+
351
+ # =================================================================
352
+ # IAM – Organizations
353
+ # =================================================================
354
+
355
+ async def create_org(self, name: str) -> Organization:
356
+ """Create a new organization. Requires superadmin or platform_admin."""
357
+ resp = await self._request("CREATE_ORG", {"name": name})
358
+ return Organization(id=resp["id"], name=resp["name"])
359
+
360
+ async def get_org(self, org_id: str = "") -> Organization:
361
+ """Get organization details. Defaults to caller's org."""
362
+ resp = await self._request("GET_ORG", {"orgId": org_id})
363
+ return Organization(
364
+ id=resp["id"], name=resp["name"], created_at=resp.get("createdAt", ""),
365
+ )
366
+
367
+ async def update_org(self, org_id: str, name: str) -> Organization:
368
+ """Rename an organization. Requires superadmin or platform_admin."""
369
+ resp = await self._request("UPDATE_ORG", {"orgId": org_id, "name": name})
370
+ return Organization(id=resp["id"], name=resp["name"])
371
+
372
+ async def delete_org(self, org_id: str) -> None:
373
+ """Delete an organization. Requires superadmin or platform_admin."""
374
+ await self._request("DELETE_ORG", {"orgId": org_id})
375
+
376
+ async def list_orgs(self) -> ListOrgsResponse:
377
+ """List all organizations. Requires superadmin or platform_admin."""
378
+ resp = await self._request("LIST_ORGS", {})
379
+ orgs = [
380
+ Organization(id=o["id"], name=o["name"], created_at=o.get("createdAt", ""))
381
+ for o in resp.get("orgs", [])
382
+ ]
383
+ return ListOrgsResponse(orgs=orgs, total=resp.get("total", 0))
384
+
385
+ # =================================================================
386
+ # IAM – Users
387
+ # =================================================================
388
+
389
+ async def create_user(
390
+ self,
391
+ username: str,
392
+ password: str,
393
+ org_id: str = "",
394
+ role: str = "member",
395
+ ) -> User:
396
+ """Create a user. Requires org admin."""
397
+ payload: dict[str, Any] = {
398
+ "username": username,
399
+ "password": password,
400
+ "role": role,
401
+ }
402
+ if org_id:
403
+ payload["orgId"] = org_id
404
+ resp = await self._request("CREATE_USER", payload)
405
+ return User(
406
+ id=resp["id"],
407
+ username=resp["username"],
408
+ org_id=resp["orgId"],
409
+ role=resp["role"],
410
+ )
411
+
412
+ async def get_user(self, user_id: str) -> User:
413
+ """Get user details. Users can always view themselves."""
414
+ resp = await self._request("GET_USER", {"userId": user_id})
415
+ return User(
416
+ id=resp["id"],
417
+ username=resp["username"],
418
+ org_id=resp["orgId"],
419
+ role=resp["role"],
420
+ platform_role=resp.get("platformRole", ""),
421
+ created_at=resp.get("createdAt", ""),
422
+ )
423
+
424
+ async def update_user(
425
+ self,
426
+ user_id: str,
427
+ role: str = "",
428
+ platform_role: str = "",
429
+ ) -> User:
430
+ """Update a user's role or platform role."""
431
+ payload: dict[str, Any] = {"userId": user_id}
432
+ if role:
433
+ payload["role"] = role
434
+ if platform_role:
435
+ payload["platformRole"] = platform_role
436
+ resp = await self._request("UPDATE_USER", payload)
437
+ return User(
438
+ id=resp["id"],
439
+ username="",
440
+ org_id="",
441
+ role=resp["role"],
442
+ platform_role=resp.get("platformRole", ""),
443
+ )
444
+
445
+ async def delete_user(self, user_id: str) -> None:
446
+ """Delete a user. Requires org admin."""
447
+ await self._request("DELETE_USER", {"userId": user_id})
448
+
449
+ async def list_users(self, org_id: str = "") -> ListUsersResponse:
450
+ """List users in an organization. Requires org admin."""
451
+ payload: dict[str, Any] = {}
452
+ if org_id:
453
+ payload["orgId"] = org_id
454
+ resp = await self._request("LIST_USERS", payload)
455
+ users = [
456
+ User(
457
+ id=u["id"],
458
+ username=u["username"],
459
+ org_id=u["orgId"],
460
+ role=u["role"],
461
+ platform_role=u.get("platformRole", ""),
462
+ )
463
+ for u in resp.get("users", [])
464
+ ]
465
+ return ListUsersResponse(users=users, total=resp.get("total", 0))
466
+
467
+ # =================================================================
468
+ # IAM – Teams
469
+ # =================================================================
470
+
471
+ async def create_team(self, name: str, org_id: str = "") -> Team:
472
+ """Create a team. Requires org admin."""
473
+ payload: dict[str, Any] = {"name": name}
474
+ if org_id:
475
+ payload["orgId"] = org_id
476
+ resp = await self._request("CREATE_TEAM", payload)
477
+ return Team(id=resp["id"], name=resp["name"], org_id=resp["orgId"])
478
+
479
+ async def get_team(self, team_id: str) -> Team:
480
+ """Get team details. Requires org admin."""
481
+ resp = await self._request("GET_TEAM", {"teamId": team_id})
482
+ return Team(
483
+ id=resp["id"],
484
+ name=resp["name"],
485
+ org_id=resp["orgId"],
486
+ created_at=resp.get("createdAt", ""),
487
+ )
488
+
489
+ async def update_team(self, team_id: str, name: str) -> Team:
490
+ """Rename a team. Requires org admin."""
491
+ resp = await self._request("UPDATE_TEAM", {"teamId": team_id, "name": name})
492
+ return Team(id=resp["id"], name=resp["name"], org_id="")
493
+
494
+ async def delete_team(self, team_id: str) -> None:
495
+ """Delete a team. Requires org admin."""
496
+ await self._request("DELETE_TEAM", {"teamId": team_id})
497
+
498
+ async def list_teams(self, org_id: str = "") -> ListTeamsResponse:
499
+ """List teams in an organization. Requires org admin."""
500
+ payload: dict[str, Any] = {}
501
+ if org_id:
502
+ payload["orgId"] = org_id
503
+ resp = await self._request("LIST_TEAMS", payload)
504
+ teams = [
505
+ Team(id=t["id"], name=t["name"], org_id=t["orgId"])
506
+ for t in resp.get("teams", [])
507
+ ]
508
+ return ListTeamsResponse(teams=teams, total=resp.get("total", 0))
509
+
510
+ # =================================================================
511
+ # IAM – Team Members
512
+ # =================================================================
513
+
514
+ async def add_team_member(
515
+ self, team_id: str, user_id: str, role: str = "member",
516
+ ) -> None:
517
+ """Add a user to a team. Requires org admin or team admin."""
518
+ await self._request("ADD_TEAM_MEMBER", {
519
+ "teamId": team_id, "userId": user_id, "role": role,
520
+ })
521
+
522
+ async def remove_team_member(self, team_id: str, user_id: str) -> None:
523
+ """Remove a user from a team. Requires org admin or team admin."""
524
+ await self._request("REMOVE_TEAM_MEMBER", {
525
+ "teamId": team_id, "userId": user_id,
526
+ })
527
+
528
+ async def list_team_members(self, team_id: str) -> ListTeamMembersResponse:
529
+ """List members of a team. Requires org admin or team admin."""
530
+ resp = await self._request("LIST_TEAM_MEMBERS", {"teamId": team_id})
531
+ members = [
532
+ TeamMember(
533
+ user_id=m["userId"], username=m["username"], role=m["role"],
534
+ )
535
+ for m in resp.get("members", [])
536
+ ]
537
+ return ListTeamMembersResponse(
538
+ team_id=resp.get("teamId", team_id),
539
+ members=members,
540
+ total=resp.get("total", 0),
541
+ )
542
+
543
+ # =================================================================
544
+ # IAM – Entity Permissions
545
+ # =================================================================
546
+
547
+ async def grant_permission(
548
+ self,
549
+ entity_id: str,
550
+ grantee_type: str,
551
+ grantee_id: str,
552
+ permission: str = "read",
553
+ ) -> None:
554
+ """Grant access to an entity. Any authenticated user can share."""
555
+ await self._request("GRANT_PERMISSION", {
556
+ "entityId": entity_id,
557
+ "granteeType": grantee_type,
558
+ "granteeId": grantee_id,
559
+ "permission": permission,
560
+ })
561
+
562
+ async def revoke_permission(
563
+ self, entity_id: str, grantee_type: str, grantee_id: str,
564
+ ) -> None:
565
+ """Revoke access to an entity."""
566
+ await self._request("REVOKE_PERMISSION", {
567
+ "entityId": entity_id,
568
+ "granteeType": grantee_type,
569
+ "granteeId": grantee_id,
570
+ })
571
+
572
+ async def list_permissions(self, entity_id: str) -> ListPermissionsResponse:
573
+ """List all permission grants on an entity."""
574
+ resp = await self._request("LIST_PERMISSIONS", {"entityId": entity_id})
575
+ perms = [
576
+ Permission(
577
+ grantee_type=p["granteeType"],
578
+ grantee_id=p["granteeId"],
579
+ permission=p["permission"],
580
+ granted_by=p.get("grantedBy", ""),
581
+ )
582
+ for p in resp.get("permissions", [])
583
+ ]
584
+ return ListPermissionsResponse(
585
+ entity_id=resp.get("entityId", entity_id),
586
+ permissions=perms,
587
+ total=resp.get("total", 0),
588
+ )
589
+
590
+ # =================================================================
591
+ # Categories
592
+ # =================================================================
593
+
594
+ async def create_category(
595
+ self, name: str, description: str = "", parent_id: str = "",
596
+ ) -> Category:
597
+ payload: dict[str, Any] = {"name": name}
598
+ if description:
599
+ payload["description"] = description
600
+ if parent_id:
601
+ payload["parentId"] = parent_id
602
+ resp = await self._request("CREATE_CATEGORY", payload)
603
+ return Category(
604
+ id=resp.get("id", ""),
605
+ name=resp.get("name", ""),
606
+ org_id=resp.get("orgId", ""),
607
+ )
608
+
609
+ async def get_category(self, category_id: str) -> Category:
610
+ resp = await self._request("GET_CATEGORY", {"categoryId": category_id})
611
+ cat = resp.get("category", {})
612
+ return _dict_to_category(cat)
613
+
614
+ async def update_category(
615
+ self,
616
+ category_id: str,
617
+ *,
618
+ name: str = "",
619
+ description: str = "",
620
+ parent_id: str = "",
621
+ clear_parent: bool = False,
622
+ ) -> Category:
623
+ payload: dict[str, Any] = {"categoryId": category_id}
624
+ if name:
625
+ payload["name"] = name
626
+ if description:
627
+ payload["description"] = description
628
+ if parent_id:
629
+ payload["parentId"] = parent_id
630
+ if clear_parent:
631
+ payload["clearParent"] = True
632
+ resp = await self._request("UPDATE_CATEGORY", payload)
633
+ return Category(
634
+ id=resp.get("id", ""),
635
+ name=resp.get("name", ""),
636
+ org_id="",
637
+ )
638
+
639
+ async def delete_category(self, category_id: str) -> None:
640
+ await self._request("DELETE_CATEGORY", {"categoryId": category_id})
641
+
642
+ async def list_categories(
643
+ self, parent_id: str = "", tree: bool = False,
644
+ ) -> ListCategoriesResponse:
645
+ payload: dict[str, Any] = {}
646
+ if parent_id:
647
+ payload["parentId"] = parent_id
648
+ if tree:
649
+ payload["tree"] = True
650
+ resp = await self._request("LIST_CATEGORIES", payload)
651
+ cats = [_dict_to_category(c) for c in resp.get("categories", [])]
652
+ return ListCategoriesResponse(categories=cats, total=resp.get("total", 0))
653
+
654
+ async def assign_entity_category(self, entity_id: str, category_id: str) -> None:
655
+ await self._request("ASSIGN_ENTITY_CATEGORY", {
656
+ "entityId": entity_id,
657
+ "categoryId": category_id,
658
+ })
659
+
660
+ async def unassign_entity_category(self, entity_id: str, category_id: str) -> None:
661
+ await self._request("UNASSIGN_ENTITY_CATEGORY", {
662
+ "entityId": entity_id,
663
+ "categoryId": category_id,
664
+ })
665
+
666
+ async def list_entity_categories(self, entity_id: str) -> ListEntityCategoriesResponse:
667
+ resp = await self._request("LIST_ENTITY_CATEGORIES", {"entityId": entity_id})
668
+ cats = [_dict_to_category(c) for c in resp.get("categories", [])]
669
+ return ListEntityCategoriesResponse(
670
+ entity_id=resp.get("entityId", entity_id),
671
+ categories=cats,
672
+ total=resp.get("total", 0),
673
+ )
674
+
675
+
676
+ def create_client(config: ClientConfig) -> TetrixClient:
677
+ """Create a new TetrixAIDb client."""
678
+ return TetrixClient(config)
679
+
680
+
681
+ # --- Internal helpers ---
682
+
683
+
684
+ def _index_request_to_dict(req: IndexRequest) -> dict[str, Any]:
685
+ entity: dict[str, Any] = {
686
+ "type": req.entity.type,
687
+ "owner": req.entity.owner,
688
+ "name": req.entity.name,
689
+ }
690
+ if req.entity.id:
691
+ entity["id"] = req.entity.id
692
+ if req.entity.metadata:
693
+ entity["metadata"] = req.entity.metadata
694
+ if req.entity.relations:
695
+ entity["relations"] = [
696
+ {"type": r.type, "targetId": r.target_id} for r in req.entity.relations
697
+ ]
698
+
699
+ content: dict[str, Any] = {"text": req.content.text}
700
+ if req.content.raw:
701
+ content["raw"] = req.content.raw
702
+ if req.content.encoding:
703
+ content["encoding"] = req.content.encoding
704
+
705
+ result: dict[str, Any] = {
706
+ "entity": entity,
707
+ "content": content,
708
+ "writeConsistency": req.write_consistency,
709
+ }
710
+ if req.category_ids:
711
+ result["categoryIds"] = req.category_ids
712
+ return result
713
+
714
+
715
+ def _dict_to_entity(d: dict[str, Any]) -> Any:
716
+ from tetrix_aidb.types import Entity, RelationRef
717
+
718
+ relations = [
719
+ RelationRef(type=r.get("type", ""), target_id=r.get("targetId", ""))
720
+ for r in d.get("relations", [])
721
+ ]
722
+ return Entity(
723
+ id=d.get("id", ""),
724
+ type=d.get("type", ""),
725
+ owner=d.get("owner", ""),
726
+ name=d.get("name", ""),
727
+ metadata=d.get("metadata", {}),
728
+ relations=relations,
729
+ version=d.get("version", ""),
730
+ )
731
+
732
+
733
+ def _dict_to_search_result(d: dict[str, Any]) -> Any:
734
+ from tetrix_aidb.types import RelatedEntity, SearchResultItem
735
+
736
+ entity = _dict_to_entity(d.get("entity", {}))
737
+ related = [
738
+ RelatedEntity(
739
+ id=r.get("id", ""),
740
+ type=r.get("type", ""),
741
+ name=r.get("name", ""),
742
+ relation_type=r.get("relationType", ""),
743
+ distance=r.get("distance", 0),
744
+ )
745
+ for r in d.get("relatedEntities", [])
746
+ ]
747
+ return SearchResultItem(
748
+ entity=entity,
749
+ score=d.get("score", 0.0),
750
+ source=d.get("source", ""),
751
+ content_preview=d.get("contentPreview"),
752
+ related_entities=related,
753
+ )
754
+
755
+
756
+ def _dict_to_category(d: dict[str, Any]) -> Category:
757
+ children = [_dict_to_category(c) for c in d.get("children", [])]
758
+ return Category(
759
+ id=d.get("id", ""),
760
+ name=d.get("name", ""),
761
+ description=d.get("description", ""),
762
+ org_id=d.get("orgId", ""),
763
+ parent_id=d.get("parentId", ""),
764
+ children=children,
765
+ created_at=d.get("createdAt", ""),
766
+ updated_at=d.get("updatedAt", ""),
767
+ )
tetrix_aidb/errors.py ADDED
@@ -0,0 +1,38 @@
1
+ """Error types for the TetrixAIDb SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class TetrixError(Exception):
9
+ """Base error raised by TetrixAIDb SDK operations."""
10
+
11
+ def __init__(
12
+ self,
13
+ code: str,
14
+ message: str,
15
+ retryable: bool = False,
16
+ details: dict[str, Any] | None = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.code = code
20
+ self.retryable = retryable
21
+ self.details = details or {}
22
+
23
+ def __repr__(self) -> str:
24
+ return f"TetrixError(code={self.code!r}, message={self.args[0]!r}, retryable={self.retryable})"
25
+
26
+
27
+ class ConnectionError(TetrixError):
28
+ """Raised when the SDK cannot connect to the daemon."""
29
+
30
+ def __init__(self, message: str) -> None:
31
+ super().__init__(code="CONNECTION_ERROR", message=message, retryable=True)
32
+
33
+
34
+ class TimeoutError(TetrixError):
35
+ """Raised when an operation exceeds the configured timeout."""
36
+
37
+ def __init__(self, message: str) -> None:
38
+ super().__init__(code="TIMEOUT", message=message, retryable=True)
@@ -0,0 +1,29 @@
1
+ """Wire protocol framing: 4-byte big-endian length prefix + JSON payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import struct
7
+ from typing import Any
8
+
9
+ HEADER_FORMAT = "!I" # big-endian uint32
10
+ HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
11
+
12
+
13
+ def encode_frame(payload: dict[str, Any]) -> bytes:
14
+ """Encode a payload dict into a length-prefixed JSON frame."""
15
+ body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
16
+ header = struct.pack(HEADER_FORMAT, len(body))
17
+ return header + body
18
+
19
+
20
+ async def read_frame(reader: Any, max_size: int = 16 << 20) -> dict[str, Any]:
21
+ """Read a single length-prefixed frame from an asyncio StreamReader."""
22
+ header = await reader.readexactly(HEADER_SIZE)
23
+ (length,) = struct.unpack(HEADER_FORMAT, header)
24
+
25
+ if length > max_size:
26
+ raise ValueError(f"frame size {length} exceeds max {max_size}")
27
+
28
+ body = await reader.readexactly(length)
29
+ return json.loads(body)
tetrix_aidb/types.py ADDED
@@ -0,0 +1,354 @@
1
+ """Core types matching the TetrixAIDb protocol specification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class ClientConfig:
11
+ endpoint: str
12
+ username: str
13
+ password: str
14
+ timeout_ms: int = 5000
15
+ max_connections: int = 256
16
+
17
+
18
+ @dataclass
19
+ class RelationRef:
20
+ type: str
21
+ target_id: str
22
+
23
+
24
+ @dataclass
25
+ class Entity:
26
+ id: str = ""
27
+ type: str = ""
28
+ owner: str = ""
29
+ name: str = ""
30
+ metadata: dict[str, Any] = field(default_factory=dict)
31
+ relations: list[RelationRef] = field(default_factory=list)
32
+ version: str = ""
33
+
34
+
35
+ @dataclass
36
+ class EntityContent:
37
+ text: str = ""
38
+ raw: str | None = None
39
+ encoding: str | None = None
40
+
41
+
42
+ @dataclass
43
+ class IndexRequest:
44
+ entity: Entity
45
+ content: EntityContent
46
+ idempotency_key: str | None = None
47
+ category_ids: list[str] | None = None
48
+ write_consistency: str = "strong"
49
+
50
+
51
+ @dataclass
52
+ class IndexResponse:
53
+ id: str
54
+ version: str
55
+ created_at: str
56
+ write_consistency: str
57
+
58
+
59
+ @dataclass
60
+ class GetRequest:
61
+ id: str
62
+ include_content: bool = True
63
+ include_relations: bool = True
64
+
65
+
66
+ @dataclass
67
+ class GetResponse:
68
+ entity: Entity
69
+ content: EntityContent | None = None
70
+
71
+
72
+ @dataclass
73
+ class UpdatePatch:
74
+ name: str | None = None
75
+ metadata: dict[str, Any] | None = None
76
+ relations: list[RelationRef] | None = None
77
+
78
+
79
+ @dataclass
80
+ class ContentPatch:
81
+ text: str | None = None
82
+ raw: str | None = None
83
+ encoding: str | None = None
84
+
85
+
86
+ @dataclass
87
+ class UpdateRequest:
88
+ id: str
89
+ expected_version: str | None = None
90
+ patch: UpdatePatch | None = None
91
+ content_patch: ContentPatch | None = None
92
+ write_consistency: str = "strong"
93
+
94
+
95
+ @dataclass
96
+ class UpdateResponse:
97
+ id: str
98
+ version: str
99
+ updated_at: str
100
+
101
+
102
+ @dataclass
103
+ class DeleteRequest:
104
+ id: str
105
+ expected_version: str | None = None
106
+ delete_mode: str = "soft"
107
+
108
+
109
+ @dataclass
110
+ class DeleteResponse:
111
+ id: str
112
+ status: str
113
+ deleted_at: str
114
+
115
+
116
+ @dataclass
117
+ class IndexBatchItem:
118
+ entity: Entity
119
+ content: EntityContent
120
+ idempotency_key: str | None = None
121
+ category_ids: list[str] | None = None
122
+
123
+
124
+ @dataclass
125
+ class IndexBatchOptions:
126
+ write_consistency: str = "strong"
127
+
128
+
129
+ @dataclass
130
+ class IndexBatchResultItem:
131
+ id: str
132
+ version: str
133
+ error: str | None = None
134
+
135
+
136
+ @dataclass
137
+ class IndexBatchResponse:
138
+ total_count: int
139
+ failed_count: int
140
+ results: list[IndexBatchResultItem]
141
+
142
+
143
+ @dataclass
144
+ class DeleteBatchResponse:
145
+ total_count: int
146
+ failed_count: int
147
+
148
+
149
+ @dataclass
150
+ class HealthResponse:
151
+ status: str
152
+ uptime_seconds: int
153
+ backends: dict[str, str] = field(default_factory=dict)
154
+
155
+
156
+ @dataclass
157
+ class MetricsResponse:
158
+ data: dict[str, Any] = field(default_factory=dict)
159
+
160
+
161
+ @dataclass
162
+ class SearchOptions:
163
+ top_k: int = 20
164
+ offset: int = 0
165
+ expand_graph: bool = False
166
+ graph_hops: int = 1
167
+ include_content_preview: bool = True
168
+ include_relations: bool = False
169
+ use_keyword: bool = True
170
+
171
+
172
+ @dataclass
173
+ class SearchFilters:
174
+ type: list[str] | None = None
175
+ category: list[str] | None = None
176
+ owner: list[str] | None = None
177
+ metadata: dict[str, Any] | None = None
178
+
179
+
180
+ @dataclass
181
+ class SemanticSearchRequest:
182
+ text: str
183
+ filters: SearchFilters | None = None
184
+ options: SearchOptions | None = None
185
+
186
+
187
+ @dataclass
188
+ class RelatedEntity:
189
+ id: str
190
+ type: str
191
+ name: str
192
+ relation_type: str
193
+ distance: int
194
+
195
+
196
+ @dataclass
197
+ class SearchResultItem:
198
+ entity: Entity
199
+ score: float
200
+ source: str # "vector" | "keyword" | "hybrid"
201
+ content_preview: str | None = None
202
+ related_entities: list[RelatedEntity] = field(default_factory=list)
203
+
204
+
205
+ @dataclass
206
+ class SemanticSearchResponse:
207
+ items: list[SearchResultItem]
208
+ total: int
209
+ next_offset: int | None = None
210
+
211
+
212
+ # =====================================================================
213
+ # IAM – Organizations
214
+ # =====================================================================
215
+
216
+
217
+ @dataclass
218
+ class Organization:
219
+ id: str
220
+ name: str
221
+ created_at: str = ""
222
+
223
+
224
+ @dataclass
225
+ class ListOrgsResponse:
226
+ orgs: list[Organization]
227
+ total: int
228
+
229
+
230
+ # =====================================================================
231
+ # IAM – Users
232
+ # =====================================================================
233
+
234
+
235
+ @dataclass
236
+ class User:
237
+ id: str
238
+ username: str
239
+ org_id: str
240
+ role: str
241
+ platform_role: str = ""
242
+ created_at: str = ""
243
+
244
+
245
+ @dataclass
246
+ class ListUsersResponse:
247
+ users: list[User]
248
+ total: int
249
+
250
+
251
+ # =====================================================================
252
+ # IAM – Teams
253
+ # =====================================================================
254
+
255
+
256
+ @dataclass
257
+ class Team:
258
+ id: str
259
+ name: str
260
+ org_id: str
261
+ created_at: str = ""
262
+
263
+
264
+ @dataclass
265
+ class ListTeamsResponse:
266
+ teams: list[Team]
267
+ total: int
268
+
269
+
270
+ # =====================================================================
271
+ # IAM – Team Members
272
+ # =====================================================================
273
+
274
+
275
+ @dataclass
276
+ class TeamMember:
277
+ user_id: str
278
+ username: str
279
+ role: str
280
+
281
+
282
+ @dataclass
283
+ class ListTeamMembersResponse:
284
+ team_id: str
285
+ members: list[TeamMember]
286
+ total: int
287
+
288
+
289
+ # =====================================================================
290
+ # IAM – Entity Permissions
291
+ # =====================================================================
292
+
293
+
294
+ @dataclass
295
+ class Permission:
296
+ grantee_type: str # "user" | "team"
297
+ grantee_id: str
298
+ permission: str # "read" | "write"
299
+ granted_by: str = ""
300
+
301
+
302
+ @dataclass
303
+ class ListPermissionsResponse:
304
+ entity_id: str
305
+ permissions: list[Permission]
306
+ total: int
307
+
308
+
309
+ # =====================================================================
310
+ # EMBED_BATCH
311
+ # =====================================================================
312
+
313
+
314
+ @dataclass
315
+ class EmbedBatchRequest:
316
+ texts: list[str]
317
+
318
+
319
+ @dataclass
320
+ class EmbedBatchResponse:
321
+ embeddings: list[list[float]]
322
+ dimensions: int
323
+ model: str = ""
324
+ cached: int = 0
325
+
326
+
327
+ # =====================================================================
328
+ # Categories
329
+ # =====================================================================
330
+
331
+
332
+ @dataclass
333
+ class Category:
334
+ id: str
335
+ name: str
336
+ org_id: str
337
+ description: str = ""
338
+ parent_id: str = ""
339
+ children: list["Category"] = field(default_factory=list)
340
+ created_at: str = ""
341
+ updated_at: str = ""
342
+
343
+
344
+ @dataclass
345
+ class ListCategoriesResponse:
346
+ categories: list[Category]
347
+ total: int
348
+
349
+
350
+ @dataclass
351
+ class ListEntityCategoriesResponse:
352
+ entity_id: str
353
+ categories: list[Category]
354
+ total: int
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: tetrix-sdk
3
+ Version: 0.1.1
4
+ Summary: TetrixAIDb Python SDK — AI-native database client
5
+ Project-URL: Repository, https://github.com/deskree-inc/tetrix-ee
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
10
+ Requires-Dist: pytest>=8.0; extra == 'dev'
11
+ Requires-Dist: ruff>=0.9; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # TetrixAIDb Python SDK
15
+
16
+ Async Python client for [TetrixAIDb](https://github.com/deskree-inc/tetrix-ee), the AI-native database daemon that unifies vector, keyword, graph, and object storage behind a single binary protocol.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install tetrix-aidb
22
+ ```
23
+
24
+ Requires Python 3.11+.
25
+
26
+ ## Quick Start
27
+
28
+ ```python
29
+ import asyncio
30
+ from tetrix_aidb import ClientConfig, TetrixClient
31
+
32
+ async def main():
33
+ client = TetrixClient(ClientConfig(
34
+ endpoint="tcp://localhost:7779",
35
+ username="admin",
36
+ password="secret",
37
+ ))
38
+ await client.connect()
39
+
40
+ health = await client.health()
41
+ print(f"Status: {health.status}")
42
+
43
+ await client.close()
44
+
45
+ asyncio.run(main())
46
+ ```
47
+
48
+ ## Features
49
+
50
+ - Async-first API built on `asyncio`
51
+ - TCP, TLS, and Unix socket transports
52
+ - Auth-on-connect with automatic session handling
53
+ - Entity indexing, semantic search, and batch operations
54
+ - IAM operations: organizations, users, teams, permissions
55
+ - Category management
56
+ - Typed request/response models
57
+
58
+ ## License
59
+
60
+ MIT
@@ -0,0 +1,8 @@
1
+ tetrix_aidb/__init__.py,sha256=JagoVNgSnYp-bNaa8gr9d3uL0_A-5lyKkHfV4IWn_F8,1336
2
+ tetrix_aidb/client.py,sha256=FYHRvlr3TQmu-BCBoq1s4xQD3g3wCOMNcz8XJVtUSCw,27839
3
+ tetrix_aidb/errors.py,sha256=ZXqaw_4w8rMQd2sRSuxTCRWuQxZzK_aRcYCSGNnHwtU,1079
4
+ tetrix_aidb/protocol.py,sha256=GPRYGoHpCAYeyYZc1hS98o7qYOTGBww7Z1uNNWnDc04,951
5
+ tetrix_aidb/types.py,sha256=RN2DSwYGKg_sC3vSWJsY1H_fjrDfHQFUW3e6M9AfdqU,6590
6
+ tetrix_sdk-0.1.1.dist-info/METADATA,sha256=KlzCn7Wanwn2ZR-sE244DqdM917OiguCmo-1bk-2WoA,1460
7
+ tetrix_sdk-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ tetrix_sdk-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any