eigenlake 0.2.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.
eigenlake/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from .client import EigenLakeClient
4
+ from . import schema
5
+
6
+ __version__ = "0.2.0"
7
+
8
+
9
+ def connect(
10
+ *,
11
+ url: str,
12
+ api_key: str | None = None,
13
+ timeout: float = 20.0,
14
+ retries: int = 2,
15
+ ) -> EigenLakeClient:
16
+ return EigenLakeClient(
17
+ url=url,
18
+ api_key=api_key,
19
+ timeout=timeout,
20
+ retries=retries,
21
+ )
22
+
23
+
24
+ def connect_local(
25
+ *,
26
+ host: str = "http://localhost",
27
+ port: int = 8000,
28
+ api_key: str | None = None,
29
+ timeout: float = 20.0,
30
+ retries: int = 2,
31
+ ) -> EigenLakeClient:
32
+ return EigenLakeClient(
33
+ url=f"{host.rstrip('/')}:{int(port)}",
34
+ api_key=api_key,
35
+ timeout=timeout,
36
+ retries=retries,
37
+ )
38
+
39
+
40
+ __all__ = [
41
+ "EigenLakeClient",
42
+ "__version__",
43
+ "connect",
44
+ "connect_local",
45
+ "schema",
46
+ ]
eigenlake/client.py ADDED
@@ -0,0 +1,532 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Iterable, Iterator, List, Literal
5
+ from urllib.parse import quote
6
+ from uuid import uuid4
7
+
8
+ from .transport import Transport
9
+
10
+
11
+ def _q(value: str) -> str:
12
+ return quote(str(value), safe="")
13
+
14
+
15
+ def _index_path(namespace: str, index: str) -> str:
16
+ return f"/v1/indexes/{_q(namespace)}/{_q(index)}"
17
+
18
+
19
+ @dataclass
20
+ class FailedRecord:
21
+ id: str
22
+ error: str
23
+
24
+
25
+ class AddManyResult(list[str]):
26
+ def __init__(self, ids: Iterable[str], *, failed_records: List[FailedRecord] | None = None):
27
+ super().__init__(ids)
28
+ self.failed_records: List[FailedRecord] = failed_records or []
29
+
30
+ @property
31
+ def number_errors(self) -> int:
32
+ return len(self.failed_records)
33
+
34
+
35
+ class IndexRecords:
36
+ def __init__(self, handle: "IndexHandle"):
37
+ self._h = handle
38
+
39
+ def add(
40
+ self,
41
+ *,
42
+ properties: Dict[str, Any],
43
+ vector: List[float],
44
+ id: str | None = None,
45
+ on_duplicate: Literal["error", "replace", "skip"] = "error",
46
+ batch_size: int = 500,
47
+ max_workers: int = 1,
48
+ ) -> str:
49
+ payload = {
50
+ "properties": properties,
51
+ "vector": vector,
52
+ "uuid": id,
53
+ "on_duplicate": on_duplicate,
54
+ "batch_size": batch_size,
55
+ "max_workers": max_workers,
56
+ }
57
+ resp = self._h._t.post(f"{self._h._path}/records/insert", json=payload).json()
58
+ return str(resp["uuid"])
59
+
60
+ @staticmethod
61
+ def _normalize_record(record: Dict[str, Any]) -> Dict[str, Any]:
62
+ item = dict(record)
63
+ if "id" in item:
64
+ item["uuid"] = item.pop("id")
65
+ return item
66
+
67
+ @staticmethod
68
+ def _normalize_vector_item(item: Dict[str, Any]) -> Dict[str, Any]:
69
+ normalized = dict(item)
70
+ if "id" in normalized:
71
+ normalized["uuid"] = normalized.pop("id")
72
+ return normalized
73
+
74
+ def add_many(
75
+ self,
76
+ records: Iterable[dict[str, Any]],
77
+ *,
78
+ on_duplicate: Literal["error", "replace", "skip"] = "error",
79
+ on_error: Literal["raise", "continue"] = "raise",
80
+ batch_size: int = 500,
81
+ max_workers: int = 1,
82
+ ) -> AddManyResult:
83
+ payload = {
84
+ "objects": [self._normalize_record(record) for record in records],
85
+ "on_duplicate": on_duplicate,
86
+ "on_error": on_error,
87
+ "batch_size": batch_size,
88
+ "max_workers": max_workers,
89
+ }
90
+ resp = self._h._t.post(f"{self._h._path}/records/insert-many", json=payload).json()
91
+ failed = [
92
+ FailedRecord(id=str(item.get("uuid") or ""), error=str(item.get("error") or ""))
93
+ for item in (resp.get("failed_objects") or [])
94
+ ]
95
+ return AddManyResult(resp.get("uuids") or [], failed_records=failed)
96
+
97
+ def add_vectors(
98
+ self,
99
+ vectors: Iterable[dict[str, Any]],
100
+ *,
101
+ batch_size: int = 500,
102
+ max_workers: int = 1,
103
+ ) -> None:
104
+ payload = {
105
+ "vectors": [self._normalize_vector_item(item) for item in vectors],
106
+ "batch_size": batch_size,
107
+ "max_workers": max_workers,
108
+ }
109
+ self._h._t.post(f"{self._h._path}/records/insert-vectors", json=payload)
110
+
111
+ def get(self, id: str, *, return_data: bool = True, return_metadata: bool = True) -> dict[str, Any] | None:
112
+ payload = {
113
+ "uuid": id,
114
+ "return_data": return_data,
115
+ "return_metadata": return_metadata,
116
+ }
117
+ resp = self._h._t.post(f"{self._h._path}/records/get-by-id", json=payload).json()
118
+ return resp.get("object")
119
+
120
+ def exists(self, id: str) -> bool:
121
+ resp = self._h._t.get(f"{self._h._path}/records/exists/{_q(id)}").json()
122
+ return bool(resp.get("exists", False))
123
+
124
+ def remove(self, id: str, *, batch_size: int = 500) -> None:
125
+ self._h._t.delete(f"{self._h._path}/records/{_q(id)}", params={"batch_size": batch_size})
126
+
127
+ def remove_many(
128
+ self,
129
+ *,
130
+ filter: Dict[str, Any],
131
+ limit: int | None = None,
132
+ delete_sql_rows: bool = False,
133
+ on_missing: Literal["skip", "error"] = "skip",
134
+ batch_size: int = 500,
135
+ background: bool = True,
136
+ ) -> Dict[str, Any]:
137
+ payload = {
138
+ "where": filter,
139
+ "limit": limit,
140
+ "delete_patent_rows": delete_sql_rows,
141
+ "on_missing_keys": on_missing,
142
+ "batch_size": batch_size,
143
+ "background": background,
144
+ }
145
+ return self._h._t.post(f"{self._h._path}/records/delete-many", json=payload).json()
146
+
147
+ def remove_job(self, job_id: int) -> Dict[str, Any]:
148
+ return self._h._t.get(f"{self._h._path}/records/delete-jobs/{int(job_id)}").json()
149
+
150
+ def update(
151
+ self,
152
+ *,
153
+ id: str,
154
+ properties: Dict[str, Any] | None = None,
155
+ vector: List[float] | None = None,
156
+ ) -> None:
157
+ payload = {
158
+ "properties": properties,
159
+ "vector": vector,
160
+ }
161
+ self._h._t.patch(f"{self._h._path}/records/{_q(id)}", json=payload)
162
+
163
+ def replace(
164
+ self,
165
+ *,
166
+ id: str,
167
+ properties: Dict[str, Any],
168
+ vector: List[float] | None = None,
169
+ ) -> None:
170
+ payload = {
171
+ "properties": properties,
172
+ "vector": vector,
173
+ }
174
+ self._h._t.put(f"{self._h._path}/records/{_q(id)}", json=payload)
175
+
176
+ def list(
177
+ self,
178
+ *,
179
+ filter: Dict[str, Any],
180
+ limit: int = 100,
181
+ after: str | None = None,
182
+ with_vector: bool = False,
183
+ with_properties: bool = True,
184
+ on_missing: Literal["skip", "error"] = "skip",
185
+ ) -> Dict[str, Any]:
186
+ payload = {
187
+ "where": filter,
188
+ "limit": limit,
189
+ "after": after,
190
+ "include_vector": with_vector,
191
+ "include_properties": with_properties,
192
+ "on_missing_keys": on_missing,
193
+ }
194
+ return self._h._t.post(f"{self._h._path}/records/get-by-filter", json=payload).json()
195
+
196
+
197
+ class IndexSearch:
198
+ def __init__(self, handle: "IndexHandle"):
199
+ self._h = handle
200
+
201
+ def nearest(self, *, vector: List[float], limit: int = 10, filter: Dict[str, Any] | None = None):
202
+ payload = {
203
+ "vector": vector,
204
+ "top_k": limit,
205
+ "filter": filter,
206
+ }
207
+ return self._h._t.post(f"{self._h._path}/search/near-vector", json=payload).json()
208
+
209
+ def cluster(
210
+ self,
211
+ *,
212
+ filter: Dict[str, Any] | None = None,
213
+ limit: int = 1000,
214
+ algorithm: Literal["kmeans"] = "kmeans",
215
+ num_clusters: int | None = None,
216
+ distance_metric: Literal["cosine", "euclidean"] = "cosine",
217
+ text_fields: List[str] | None = None,
218
+ representatives_per_cluster: int = 3,
219
+ ) -> Dict[str, Any]:
220
+ payload = {
221
+ "filter": filter or {},
222
+ "limit": limit,
223
+ "algorithm": algorithm,
224
+ "num_clusters": num_clusters,
225
+ "distance_metric": distance_metric,
226
+ "text_fields": text_fields or [],
227
+ "representatives_per_cluster": representatives_per_cluster,
228
+ }
229
+ return self._h._t.post(f"{self._h._path}/search/cluster", json=payload).json()
230
+
231
+ def get(self, id: str, *, with_vector: bool = False) -> Dict[str, Any]:
232
+ params = {"include_vector": bool(with_vector)}
233
+ return self._h._t.get(f"{self._h._path}/search/object/{_q(id)}", params=params).json()
234
+
235
+ def list(
236
+ self,
237
+ *,
238
+ limit: int = 100,
239
+ offset: int = 0,
240
+ with_vector: bool = False,
241
+ with_properties: bool = True,
242
+ newest_first: bool = True,
243
+ ) -> Dict[str, Any]:
244
+ params = {
245
+ "limit": limit,
246
+ "offset": offset,
247
+ "include_vector": with_vector,
248
+ "include_properties": with_properties,
249
+ "newest_first": newest_first,
250
+ }
251
+ return self._h._t.get(f"{self._h._path}/search/objects", params=params).json()
252
+
253
+ def iterate(
254
+ self,
255
+ *,
256
+ page_size: int = 500,
257
+ with_vector: bool = False,
258
+ with_properties: bool = True,
259
+ newest_first: bool = True,
260
+ ) -> Iterator[dict[str, Any]]:
261
+ offset = 0
262
+ while True:
263
+ page = self.list(
264
+ limit=page_size,
265
+ offset=offset,
266
+ with_vector=with_vector,
267
+ with_properties=with_properties,
268
+ newest_first=newest_first,
269
+ )
270
+ objects = page.get("objects") or []
271
+ if not objects:
272
+ break
273
+ for obj in objects:
274
+ yield obj
275
+ offset = int(page.get("next_offset") or 0)
276
+
277
+
278
+ class IndexSettings:
279
+ def __init__(self, handle: "IndexHandle"):
280
+ self._h = handle
281
+
282
+ def _read(self) -> dict[str, Any]:
283
+ return self._h._t.get(f"{self._h._path}/settings").json()
284
+
285
+ def dimensions(self) -> int:
286
+ return int(self._read().get("dims", 0))
287
+
288
+ def schema(self) -> Dict[str, Any]:
289
+ return dict(self._read().get("schema") or {})
290
+
291
+ def shards(self) -> Dict[str, Any]:
292
+ return dict(self._read().get("shards") or {})
293
+
294
+
295
+ class IndexManage:
296
+ def __init__(self, handle: "IndexHandle"):
297
+ self._h = handle
298
+
299
+ def delete(self, *, ensure_remote: bool = True, drop_keys_table: bool = True):
300
+ params = {
301
+ "ensure_remote": ensure_remote,
302
+ "drop_keys_table": drop_keys_table,
303
+ }
304
+ return self._h._t.delete(self._h._path, params=params).json()
305
+
306
+ def remove_by_filter(
307
+ self,
308
+ *,
309
+ filter: Dict[str, Any],
310
+ limit_ids: int | None = None,
311
+ delete_sql_rows: bool = False,
312
+ on_missing: Literal["skip", "error"] = "skip",
313
+ batch_size: int = 500,
314
+ background: bool = True,
315
+ ) -> Dict[str, Any]:
316
+ payload = {
317
+ "where": filter,
318
+ "limit_object_ids": limit_ids,
319
+ "delete_sql_metadata_rows": delete_sql_rows,
320
+ "on_missing_keys": on_missing,
321
+ "batch_size": batch_size,
322
+ "background": background,
323
+ }
324
+ return self._h._t.post(f"{self._h._path}/manage/delete-by-filter", json=payload).json()
325
+
326
+
327
+ class IndexBatch:
328
+ def __init__(self, handle: "IndexHandle"):
329
+ self._h = handle
330
+ self.failed_records: List[FailedRecord] = []
331
+
332
+ def with_size(
333
+ self,
334
+ *,
335
+ batch_size: int = 200,
336
+ max_workers: int = 1,
337
+ on_error: Literal["raise", "continue"] = "continue",
338
+ ):
339
+ return _SizedBatchWriter(
340
+ manager=self,
341
+ batch_size=int(batch_size),
342
+ max_workers=int(max_workers),
343
+ on_error=on_error,
344
+ )
345
+
346
+
347
+ class _SizedBatchWriter:
348
+ def __init__(
349
+ self,
350
+ *,
351
+ manager: IndexBatch,
352
+ batch_size: int,
353
+ max_workers: int,
354
+ on_error: Literal["raise", "continue"],
355
+ ):
356
+ self._m = manager
357
+ self._batch_size = max(1, batch_size)
358
+ self._max_workers = max(1, max_workers)
359
+ self._on_error = on_error
360
+
361
+ self._buffer: List[dict[str, Any]] = []
362
+ self.failed_records: List[FailedRecord] = []
363
+ self.number_errors: int = 0
364
+
365
+ def __enter__(self):
366
+ return self
367
+
368
+ def __exit__(self, exc_type, exc, tb):
369
+ self.flush()
370
+ self._m.failed_records = list(self.failed_records)
371
+ return False
372
+
373
+ def add(
374
+ self,
375
+ *,
376
+ properties: Dict[str, Any],
377
+ vector: List[float],
378
+ id: str | None = None,
379
+ ) -> str:
380
+ out_id = str(id) if id is not None else str(uuid4())
381
+ self._buffer.append(
382
+ {
383
+ "properties": properties,
384
+ "vector": vector,
385
+ "id": out_id,
386
+ }
387
+ )
388
+ if len(self._buffer) >= self._batch_size:
389
+ self.flush()
390
+ return out_id
391
+
392
+ def flush(self) -> None:
393
+ if not self._buffer:
394
+ return
395
+
396
+ payload = list(self._buffer)
397
+ self._buffer = []
398
+
399
+ try:
400
+ result = self._m._h.records.add_many(
401
+ payload,
402
+ on_duplicate="error",
403
+ on_error="continue",
404
+ batch_size=self._batch_size,
405
+ max_workers=self._max_workers,
406
+ )
407
+ self.number_errors += result.number_errors
408
+ self.failed_records.extend(result.failed_records)
409
+ except Exception as exc:
410
+ self.number_errors += len(payload)
411
+ self.failed_records.extend(
412
+ [FailedRecord(id=str(item.get("id") or ""), error=str(exc)) for item in payload]
413
+ )
414
+ if self._on_error == "raise":
415
+ raise
416
+
417
+
418
+ class IndexAgent:
419
+ def __init__(self, handle: "IndexHandle"):
420
+ self._h = handle
421
+
422
+ def query(
423
+ self,
424
+ query: str,
425
+ *,
426
+ filter: Dict[str, Any] | None = None,
427
+ limit: int = 1000,
428
+ mode: Literal["auto", "cluster", "filter"] = "auto",
429
+ num_clusters: int | None = None,
430
+ text_fields: List[str] | None = None,
431
+ time_field: str = "created_at",
432
+ failure_field: str = "status",
433
+ failure_values: List[str] | None = None,
434
+ recent_days: int = 7,
435
+ ) -> Dict[str, Any]:
436
+ payload = {
437
+ "query": query,
438
+ "filter": filter or {},
439
+ "limit": limit,
440
+ "mode": mode,
441
+ "num_clusters": num_clusters,
442
+ "text_fields": text_fields or [],
443
+ "time_field": time_field,
444
+ "failure_field": failure_field,
445
+ "failure_values": failure_values or ["failure", "failed", "error"],
446
+ "recent_days": recent_days,
447
+ }
448
+ return self._h._t.post(f"{self._h._path}/agent/query", json=payload).json()
449
+
450
+
451
+ class IndexHandle:
452
+ def __init__(self, transport: Transport, namespace: str, index: str):
453
+ self._t = transport
454
+ self._path = _index_path(namespace, index)
455
+
456
+ self.records = IndexRecords(self)
457
+ self.search = IndexSearch(self)
458
+ self.settings = IndexSettings(self)
459
+ self.manage = IndexManage(self)
460
+ self.batch = IndexBatch(self)
461
+ self.agent = IndexAgent(self)
462
+
463
+
464
+ class IndexesNamespace:
465
+ def __init__(self, transport: Transport):
466
+ self._t = transport
467
+
468
+ def create_or_get(
469
+ self,
470
+ *,
471
+ namespace: str,
472
+ index: str,
473
+ dimensions: int,
474
+ schema: Dict[str, Any] | None = None,
475
+ index_options: Dict[str, Any] | None = None,
476
+ shard_count: int = 1,
477
+ record_id_property: str = "document_id",
478
+ ) -> IndexHandle:
479
+ shard_count = max(1, int(shard_count))
480
+ payload = {
481
+ "namespace": namespace,
482
+ "index": index,
483
+ "dimensions": int(dimensions),
484
+ "schema": schema,
485
+ "index_options": index_options,
486
+ "shard_count": shard_count,
487
+ "record_id_property": record_id_property,
488
+ }
489
+ self._t.post("/v1/indexes/get-or-create", json=payload)
490
+ return IndexHandle(self._t, namespace, index)
491
+
492
+ def open(self, *, namespace: str, index: str) -> IndexHandle:
493
+ self._t.get(_index_path(namespace, index))
494
+ return IndexHandle(self._t, namespace, index)
495
+
496
+ def ref(self, *, namespace: str, index: str) -> IndexHandle:
497
+ return IndexHandle(self._t, namespace, index)
498
+
499
+
500
+ class EigenLakeClient:
501
+ def __init__(
502
+ self,
503
+ *,
504
+ url: str,
505
+ api_key: str | None = None,
506
+ timeout: float = 20.0,
507
+ retries: int = 2,
508
+ ):
509
+ self._transport = Transport(
510
+ base_url=url,
511
+ api_key=api_key,
512
+ timeout=timeout,
513
+ retries=retries,
514
+ )
515
+ self.indexes = IndexesNamespace(self._transport)
516
+
517
+ def ready(self) -> bool:
518
+ try:
519
+ payload = self._transport.get("/v1/health/ready").json()
520
+ return bool(payload.get("ready"))
521
+ except Exception:
522
+ return False
523
+
524
+ def close(self) -> None:
525
+ self._transport.close()
526
+
527
+ def __enter__(self) -> "EigenLakeClient":
528
+ return self
529
+
530
+ def __exit__(self, exc_type, exc, tb) -> bool:
531
+ self.close()
532
+ return False
eigenlake/errors.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class EigenlakeError(Exception):
5
+ """Base error for the Eigenlake SDK."""
6
+
7
+
8
+ class AuthenticationError(EigenlakeError):
9
+ pass
10
+
11
+
12
+ class NotFoundError(EigenlakeError):
13
+ pass
14
+
15
+
16
+ class ConflictError(EigenlakeError):
17
+ pass
18
+
19
+
20
+ class ValidationError(EigenlakeError):
21
+ pass
22
+
23
+
24
+ class APIError(EigenlakeError):
25
+ pass
26
+
27
+
28
+ class NetworkError(EigenlakeError):
29
+ pass
eigenlake/py.typed ADDED
@@ -0,0 +1 @@
1
+
eigenlake/schema.py ADDED
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Field:
9
+ field_type: str
10
+ required: bool = False
11
+ filterable: bool = True
12
+ format: str | None = None
13
+ description: str | None = None
14
+ enum: list[Any] | None = None
15
+ minimum: int | float | None = None
16
+ maximum: int | float | None = None
17
+ min_length: int | None = None
18
+ max_length: int | None = None
19
+ pattern: str | None = None
20
+ items: dict[str, Any] | None = None
21
+ min_items: int | None = None
22
+ max_items: int | None = None
23
+ unique_items: bool = False
24
+
25
+ def to_json_schema(self) -> dict[str, Any]:
26
+ out: dict[str, Any] = {"type": self.field_type}
27
+ if self.format is not None:
28
+ out["format"] = self.format
29
+ if self.description is not None:
30
+ out["description"] = self.description
31
+ if self.enum is not None:
32
+ out["enum"] = list(self.enum)
33
+ if self.minimum is not None:
34
+ out["minimum"] = self.minimum
35
+ if self.maximum is not None:
36
+ out["maximum"] = self.maximum
37
+ if self.min_length is not None:
38
+ out["minLength"] = self.min_length
39
+ if self.max_length is not None:
40
+ out["maxLength"] = self.max_length
41
+ if self.pattern is not None:
42
+ out["pattern"] = self.pattern
43
+ if self.items is not None:
44
+ out["items"] = dict(self.items)
45
+ if self.min_items is not None:
46
+ out["minItems"] = self.min_items
47
+ if self.max_items is not None:
48
+ out["maxItems"] = self.max_items
49
+ if self.unique_items:
50
+ out["uniqueItems"] = True
51
+ return out
52
+
53
+
54
+ def string(
55
+ *,
56
+ required: bool = False,
57
+ filterable: bool = True,
58
+ format: str | None = None,
59
+ description: str | None = None,
60
+ enum: list[str] | None = None,
61
+ min_length: int | None = None,
62
+ max_length: int | None = None,
63
+ pattern: str | None = None,
64
+ ) -> Field:
65
+ return Field(
66
+ field_type="string",
67
+ required=required,
68
+ filterable=filterable,
69
+ format=format,
70
+ description=description,
71
+ enum=list(enum) if enum is not None else None,
72
+ min_length=min_length,
73
+ max_length=max_length,
74
+ pattern=pattern,
75
+ )
76
+
77
+
78
+ def integer(
79
+ *,
80
+ required: bool = False,
81
+ filterable: bool = True,
82
+ description: str | None = None,
83
+ minimum: int | None = None,
84
+ maximum: int | None = None,
85
+ ) -> Field:
86
+ return Field(
87
+ field_type="integer",
88
+ required=required,
89
+ filterable=filterable,
90
+ description=description,
91
+ minimum=minimum,
92
+ maximum=maximum,
93
+ )
94
+
95
+
96
+ def number(
97
+ *,
98
+ required: bool = False,
99
+ filterable: bool = True,
100
+ description: str | None = None,
101
+ minimum: int | float | None = None,
102
+ maximum: int | float | None = None,
103
+ ) -> Field:
104
+ return Field(
105
+ field_type="number",
106
+ required=required,
107
+ filterable=filterable,
108
+ description=description,
109
+ minimum=minimum,
110
+ maximum=maximum,
111
+ )
112
+
113
+
114
+ def boolean(
115
+ *,
116
+ required: bool = False,
117
+ filterable: bool = True,
118
+ description: str | None = None,
119
+ ) -> Field:
120
+ return Field(
121
+ field_type="boolean",
122
+ required=required,
123
+ filterable=filterable,
124
+ description=description,
125
+ )
126
+
127
+
128
+ def array(
129
+ item: Field | dict[str, Any],
130
+ *,
131
+ required: bool = False,
132
+ filterable: bool = True,
133
+ description: str | None = None,
134
+ min_items: int | None = None,
135
+ max_items: int | None = None,
136
+ unique_items: bool = False,
137
+ ) -> Field:
138
+ if isinstance(item, Field):
139
+ item_schema = item.to_json_schema()
140
+ else:
141
+ item_schema = dict(item)
142
+ return Field(
143
+ field_type="array",
144
+ required=required,
145
+ filterable=filterable,
146
+ description=description,
147
+ items=item_schema,
148
+ min_items=min_items,
149
+ max_items=max_items,
150
+ unique_items=unique_items,
151
+ )
152
+
153
+
154
+ def datetime(
155
+ *,
156
+ required: bool = False,
157
+ filterable: bool = True,
158
+ description: str | None = None,
159
+ ) -> Field:
160
+ return string(
161
+ required=required,
162
+ filterable=filterable,
163
+ format="date-time",
164
+ description=description,
165
+ )
166
+
167
+
168
+ def date(
169
+ *,
170
+ required: bool = False,
171
+ filterable: bool = True,
172
+ description: str | None = None,
173
+ ) -> Field:
174
+ return string(
175
+ required=required,
176
+ filterable=filterable,
177
+ format="date",
178
+ description=description,
179
+ )
180
+
181
+
182
+ class SchemaBuilder:
183
+ def __init__(self, *, additional_properties: bool = False):
184
+ self._additional_properties = bool(additional_properties)
185
+ self._fields: dict[str, Field] = {}
186
+
187
+ def add(self, name: str, field: Field) -> "SchemaBuilder":
188
+ self._fields[str(name)] = field
189
+ return self
190
+
191
+ def build(self) -> tuple[dict[str, Any], dict[str, Any]]:
192
+ properties = {name: field.to_json_schema() for name, field in self._fields.items()}
193
+ required = [name for name, field in self._fields.items() if field.required]
194
+ non_filterable = [name for name, field in self._fields.items() if not field.filterable]
195
+
196
+ schema: dict[str, Any] = {
197
+ "type": "object",
198
+ "additionalProperties": self._additional_properties,
199
+ "properties": properties,
200
+ }
201
+ if required:
202
+ schema["required"] = required
203
+
204
+ index_options: dict[str, Any] = {
205
+ "metadataConfiguration": {
206
+ "nonFilterableMetadataKeys": non_filterable,
207
+ }
208
+ }
209
+ return schema, index_options
eigenlake/transport.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from .errors import APIError, AuthenticationError, ConflictError, NetworkError, NotFoundError, ValidationError
9
+
10
+
11
+ class Transport:
12
+ def __init__(
13
+ self,
14
+ *,
15
+ base_url: str,
16
+ api_key: str | None,
17
+ timeout: float = 20.0,
18
+ retries: int = 2,
19
+ ):
20
+ normalized = base_url.rstrip("/")
21
+ self._retries = max(0, int(retries))
22
+ self._client = httpx.Client(
23
+ base_url=normalized,
24
+ timeout=float(timeout),
25
+ headers=self._auth_headers(api_key),
26
+ )
27
+
28
+ @staticmethod
29
+ def _auth_headers(api_key: str | None) -> dict[str, str]:
30
+ token = (api_key or "").strip()
31
+ if not token:
32
+ return {}
33
+ return {"X-API-Key": token}
34
+
35
+ @staticmethod
36
+ def _detail(resp: httpx.Response) -> str:
37
+ try:
38
+ payload = resp.json()
39
+ if isinstance(payload, dict) and payload.get("detail") is not None:
40
+ return str(payload["detail"])
41
+ except Exception:
42
+ pass
43
+ text = (resp.text or "").strip()
44
+ return text or f"HTTP {resp.status_code}"
45
+
46
+ def _raise_for_status(self, resp: httpx.Response) -> None:
47
+ if 200 <= resp.status_code < 300:
48
+ return
49
+
50
+ detail = self._detail(resp)
51
+ code = int(resp.status_code)
52
+
53
+ if code in (401, 403):
54
+ raise AuthenticationError(detail)
55
+ if code == 404:
56
+ raise NotFoundError(detail)
57
+ if code == 409:
58
+ raise ConflictError(detail)
59
+ if code in (400, 422):
60
+ raise ValidationError(detail)
61
+ raise APIError(detail)
62
+
63
+ def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
64
+ path = path if path.startswith("/") else f"/{path}"
65
+
66
+ last_exc: Exception | None = None
67
+ for attempt in range(self._retries + 1):
68
+ try:
69
+ resp = self._client.request(method.upper(), path, **kwargs)
70
+ except httpx.RequestError as exc:
71
+ last_exc = exc
72
+ if attempt >= self._retries:
73
+ raise NetworkError(str(exc)) from exc
74
+ time.sleep(0.2 * (attempt + 1))
75
+ continue
76
+
77
+ if resp.status_code >= 500 and attempt < self._retries:
78
+ time.sleep(0.2 * (attempt + 1))
79
+ continue
80
+
81
+ self._raise_for_status(resp)
82
+ return resp
83
+
84
+ if last_exc is not None:
85
+ raise NetworkError(str(last_exc)) from last_exc
86
+ raise NetworkError("Request failed")
87
+
88
+ def get(self, path: str, **kwargs: Any) -> httpx.Response:
89
+ return self.request("GET", path, **kwargs)
90
+
91
+ def post(self, path: str, **kwargs: Any) -> httpx.Response:
92
+ return self.request("POST", path, **kwargs)
93
+
94
+ def delete(self, path: str, **kwargs: Any) -> httpx.Response:
95
+ return self.request("DELETE", path, **kwargs)
96
+
97
+ def patch(self, path: str, **kwargs: Any) -> httpx.Response:
98
+ return self.request("PATCH", path, **kwargs)
99
+
100
+ def put(self, path: str, **kwargs: Any) -> httpx.Response:
101
+ return self.request("PUT", path, **kwargs)
102
+
103
+ def close(self) -> None:
104
+ self._client.close()
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: eigenlake
3
+ Version: 0.2.0
4
+ Summary: Python SDK for EigenLake Cloud
5
+ Project-URL: Homepage, https://eigenlake.dev
6
+ Project-URL: Documentation, https://docs.eigenlake.dev
7
+ Project-URL: Repository, https://github.com/EigenLake-Org/eigenlake-client
8
+ Project-URL: Issues, https://github.com/EigenLake-Org/eigenlake-client/issues
9
+ Author: EigenLake
10
+ Keywords: agent,clustering,eigenlake,embeddings,vector-search
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27.0
23
+ Provides-Extra: docs
24
+ Requires-Dist: mkdocs-material>=9.5.0; extra == 'docs'
25
+ Requires-Dist: mkdocstrings[python]>=0.25.0; extra == 'docs'
26
+ Requires-Dist: pymdown-extensions>=10.0.0; extra == 'docs'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # EigenLake Python Client
30
+
31
+ Python SDK for EigenLake Cloud.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install eigenlake
37
+ ```
38
+
39
+ ## Quickstart
40
+
41
+ ```python
42
+ import eigenlake
43
+ from eigenlake import schema as s
44
+
45
+ with eigenlake.connect(
46
+ url="https://api.eigenlake.dev",
47
+ api_key="<sk_sbx_your_api_key_here>",
48
+ ) as client:
49
+ schema, index_options = (
50
+ s.SchemaBuilder(additional_properties=False)
51
+ .add("document_id", s.string(required=True, filterable=True))
52
+ .add("text", s.string(filterable=False))
53
+ .add("created_at", s.datetime(filterable=True))
54
+ .build()
55
+ )
56
+
57
+ idx = client.indexes.create_or_get(
58
+ namespace="demo-namespace",
59
+ index="demo-index",
60
+ dimensions=128,
61
+ schema=schema,
62
+ index_options=index_options,
63
+ )
64
+
65
+ record_id = idx.records.add(
66
+ properties={"document_id": "doc-1", "text": "hello"},
67
+ vector=[0.1] * 128,
68
+ )
69
+
70
+ result = idx.search.nearest(
71
+ vector=[0.1] * 128,
72
+ limit=3,
73
+ )
74
+ print(record_id, result)
75
+ ```
76
+
77
+ ## Agent Query
78
+
79
+ ```python
80
+ import eigenlake
81
+
82
+ with eigenlake.connect(url="https://api.eigenlake.dev", api_key="<sk_sbx_your_api_key_here>") as client:
83
+ idx = client.indexes.open(namespace="demo-automotive", index="automotive-fault-clustering")
84
+ result = idx.agent.query("show me recent battery failures")
85
+ print(result["filter"])
86
+ ```
87
+
88
+ ## Docs
89
+
90
+ - Documentation source: `docs/`
91
+ - Docs site config: `mkdocs.yml`
92
+
93
+ ```bash
94
+ pip install -e ".[docs]"
95
+ mkdocs serve
96
+ ```
97
+
98
+ Docs will be available at `http://127.0.0.1:8000`.
@@ -0,0 +1,9 @@
1
+ eigenlake/__init__.py,sha256=jWzMPa7Pr0NoMfktENY7D1NSzkOqgj95OpDufdKFh1g,834
2
+ eigenlake/client.py,sha256=fEfDuUedWZUlWBZc7bjCGPBs7I5Ic7QEZW6qJEGuF-k,16316
3
+ eigenlake/errors.py,sha256=4edjrFl99gGVij9iO3bTF015GlyUfv6HraLzVRH3D-s,404
4
+ eigenlake/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
5
+ eigenlake/schema.py,sha256=TyH--q4YbNHaSkmIgEglayt04-9XfkUDATn52plTe0k,5652
6
+ eigenlake/transport.py,sha256=f2pxpsaCjXA8hfKCS7PeWAsmf0vRUz74TJmCoFatQAw,3281
7
+ eigenlake-0.2.0.dist-info/METADATA,sha256=VnIb1oq56Arz-YPaVaS9EZZs4A3CHynWn38I406wbks,2757
8
+ eigenlake-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ eigenlake-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any