arkivector 1.0.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.
arkivector/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """Arkivector Python SDK."""
2
+
3
+ from arkivector.client import Client, build_entity_attributes
4
+ from arkivector.errors import (
5
+ ApiError,
6
+ AuthError,
7
+ ConflictError,
8
+ NotFoundError,
9
+ NotImplementedByDaemon,
10
+ TransportError,
11
+ UnavailableError,
12
+ ValidationError,
13
+ VectorBytesError,
14
+ error_from_problem,
15
+ )
16
+ from arkivector.filter import F
17
+ from arkivector.models import (
18
+ BatchResult,
19
+ Collection,
20
+ Column,
21
+ ColumnDef,
22
+ Consistency,
23
+ CountResult,
24
+ DirectAttribute,
25
+ Entity,
26
+ EntityInput,
27
+ IndexDef,
28
+ PreparedTransaction,
29
+ PrepareResult,
30
+ Readiness,
31
+ ScrollPage,
32
+ SearchHit,
33
+ SearchResult,
34
+ SyncStatus,
35
+ T,
36
+ Ttl,
37
+ TxResult,
38
+ TypedValue,
39
+ UpsertItemResult,
40
+ UpsertResult,
41
+ VersionInfo,
42
+ )
43
+ from arkivector.types import (
44
+ AnyVector,
45
+ Bit,
46
+ HalfVector,
47
+ SparseVector,
48
+ Vector,
49
+ )
50
+ from arkivector.vectorbytes import vectorbytes_decode_any
51
+
52
+ __version__ = "0.1.0"
53
+ SUPPORTED_API_MAJOR = 1
54
+
55
+ __all__ = [
56
+ "__version__",
57
+ "SUPPORTED_API_MAJOR",
58
+ "AnyVector",
59
+ "ApiError",
60
+ "AuthError",
61
+ "BatchResult",
62
+ "Bit",
63
+ "Client",
64
+ "Collection",
65
+ "Column",
66
+ "ColumnDef",
67
+ "ConflictError",
68
+ "Consistency",
69
+ "CountResult",
70
+ "DirectAttribute",
71
+ "Entity",
72
+ "EntityInput",
73
+ "F",
74
+ "HalfVector",
75
+ "IndexDef",
76
+ "NotFoundError",
77
+ "NotImplementedByDaemon",
78
+ "PrepareResult",
79
+ "PreparedTransaction",
80
+ "Readiness",
81
+ "ScrollPage",
82
+ "SearchHit",
83
+ "SearchResult",
84
+ "SparseVector",
85
+ "SyncStatus",
86
+ "T",
87
+ "TransportError",
88
+ "Ttl",
89
+ "TxResult",
90
+ "TypedValue",
91
+ "UnavailableError",
92
+ "UpsertItemResult",
93
+ "UpsertResult",
94
+ "ValidationError",
95
+ "Vector",
96
+ "VectorBytesError",
97
+ "VersionInfo",
98
+ "build_entity_attributes",
99
+ "error_from_problem",
100
+ "vectorbytes_decode_any",
101
+ ]
arkivector/_http.py ADDED
@@ -0,0 +1,420 @@
1
+ """Thread-safe HTTP transport with retry engine and problem error mapping."""
2
+
3
+ import http.client
4
+ import json
5
+ import random
6
+ import socket
7
+ import threading
8
+ import time
9
+ import urllib.parse
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from typing import Any, Dict, List, Optional, Tuple, Union
13
+
14
+ from arkivector.errors import ApiError, TransportError, error_from_problem
15
+
16
+ SDK_VERSION = "0.1.0"
17
+ USER_AGENT = f"arkivector-python/{SDK_VERSION}"
18
+
19
+
20
+ @dataclass
21
+ class HttpResponse:
22
+ """HTTP response container carrying status, headers, and body."""
23
+
24
+ status: int
25
+ headers: Dict[str, str]
26
+ body: bytes
27
+ request_id: str
28
+
29
+ def json(self) -> Any:
30
+ """Parse response body as JSON."""
31
+ if not self.body:
32
+ return None
33
+ return json.loads(self.body.decode("utf-8"))
34
+
35
+ @property
36
+ def as_of_block(self) -> Optional[int]:
37
+ """Block number reported by the Arkivector daemon."""
38
+ val = self.headers.get("arkivector-as-of-block")
39
+ return int(val) if val is not None else None
40
+
41
+
42
+ class ConnectionPool:
43
+ """Thread-safe HTTP/HTTPS connection pool."""
44
+
45
+ def __init__(self, idle_timeout: float = 4.0) -> None:
46
+ self._idle_timeout = idle_timeout
47
+ self._pool: Dict[
48
+ Tuple[str, str, int],
49
+ List[
50
+ Tuple[
51
+ Union[http.client.HTTPConnection, http.client.HTTPSConnection],
52
+ float,
53
+ ]
54
+ ],
55
+ ] = {}
56
+ self._lock = threading.Lock()
57
+
58
+ def acquire(
59
+ self,
60
+ scheme: str,
61
+ host: str,
62
+ port: int,
63
+ timeout: float,
64
+ ) -> Tuple[Union[http.client.HTTPConnection, http.client.HTTPSConnection], bool]:
65
+ """Acquire a connection from the pool or create a new one. Returns (conn, is_reused)."""
66
+ key = (scheme, host, port)
67
+ now = time.monotonic()
68
+ with self._lock:
69
+ conns = self._pool.get(key)
70
+ while conns:
71
+ conn, last_used = conns.pop()
72
+ if now - last_used < self._idle_timeout:
73
+ return conn, True
74
+ else:
75
+ try:
76
+ conn.close()
77
+ except Exception:
78
+ pass
79
+
80
+ if scheme == "https":
81
+ return http.client.HTTPSConnection(host, port, timeout=timeout), False
82
+ return http.client.HTTPConnection(host, port, timeout=timeout), False
83
+
84
+ def release(
85
+ self,
86
+ scheme: str,
87
+ host: str,
88
+ port: int,
89
+ conn: Union[http.client.HTTPConnection, http.client.HTTPSConnection],
90
+ should_keep_alive: bool,
91
+ ) -> None:
92
+ """Return a connection to the pool or close it."""
93
+ if not should_keep_alive:
94
+ try:
95
+ conn.close()
96
+ except Exception:
97
+ pass
98
+ return
99
+
100
+ key = (scheme, host, port)
101
+ with self._lock:
102
+ conns = self._pool.setdefault(key, [])
103
+ conns.append((conn, time.monotonic()))
104
+
105
+ def close(self) -> None:
106
+ """Close all pooled connections."""
107
+ with self._lock:
108
+ for conns in self._pool.values():
109
+ for conn, _ in conns:
110
+ try:
111
+ conn.close()
112
+ except Exception:
113
+ pass
114
+ self._pool.clear()
115
+
116
+
117
+ class HttpTransport:
118
+ """Thread-safe HTTP transport with retry engine."""
119
+
120
+ def __init__(
121
+ self,
122
+ base_url: str = "http://127.0.0.1:8080",
123
+ api_key: Optional[str] = None,
124
+ timeout: float = 10.0,
125
+ max_retries: int = 3,
126
+ ) -> None:
127
+ self.base_url = base_url.rstrip("/")
128
+ self.api_key = api_key
129
+ self.timeout = timeout
130
+ self.max_retries = max_retries
131
+
132
+ parsed = urllib.parse.urlsplit(self.base_url)
133
+ self.scheme = parsed.scheme.lower()
134
+ if self.scheme not in ("http", "https"):
135
+ raise ValueError(f"Invalid URL scheme: {self.scheme!r}")
136
+ self.host = parsed.hostname or "127.0.0.1"
137
+ self.port = parsed.port or (443 if self.scheme == "https" else 80)
138
+ self.base_path = parsed.path.rstrip("/")
139
+ self._pool = ConnectionPool()
140
+
141
+ def _send_once(
142
+ self,
143
+ method: str,
144
+ path: str,
145
+ headers: Dict[str, str],
146
+ body_bytes: Optional[bytes],
147
+ timeout: float,
148
+ ) -> HttpResponse:
149
+ conn, is_reused = self._pool.acquire(self.scheme, self.host, self.port, timeout=timeout)
150
+ try:
151
+ conn.request(method, path, body=body_bytes, headers=headers)
152
+ resp = conn.getresponse()
153
+ status = resp.status
154
+ resp_headers = {k.lower(): v for k, v in resp.getheaders()}
155
+ resp_body = resp.read()
156
+
157
+ will_close = (
158
+ getattr(resp, "will_close", False)
159
+ or resp_headers.get("connection", "").lower() == "close"
160
+ )
161
+ self._pool.release(
162
+ self.scheme, self.host, self.port, conn, should_keep_alive=not will_close
163
+ )
164
+
165
+ req_id = resp_headers.get("x-request-id") or headers.get("X-Request-Id", "")
166
+ return HttpResponse(
167
+ status=status,
168
+ headers=resp_headers,
169
+ body=resp_body,
170
+ request_id=req_id,
171
+ )
172
+ except (http.client.RemoteDisconnected, BrokenPipeError, ConnectionResetError) as e:
173
+ try:
174
+ conn.close()
175
+ except Exception:
176
+ pass
177
+ if is_reused:
178
+ conn_fresh, _ = self._pool.acquire(
179
+ self.scheme, self.host, self.port, timeout=timeout
180
+ )
181
+ try:
182
+ conn_fresh.request(method, path, body=body_bytes, headers=headers)
183
+ resp = conn_fresh.getresponse()
184
+ status = resp.status
185
+ resp_headers = {k.lower(): v for k, v in resp.getheaders()}
186
+ resp_body = resp.read()
187
+ will_close = (
188
+ getattr(resp, "will_close", False)
189
+ or resp_headers.get("connection", "").lower() == "close"
190
+ )
191
+ self._pool.release(
192
+ self.scheme,
193
+ self.host,
194
+ self.port,
195
+ conn_fresh,
196
+ should_keep_alive=not will_close,
197
+ )
198
+ req_id = resp_headers.get("x-request-id") or headers.get("X-Request-Id", "")
199
+ return HttpResponse(
200
+ status=status,
201
+ headers=resp_headers,
202
+ body=resp_body,
203
+ request_id=req_id,
204
+ )
205
+ except Exception as inner_e:
206
+ try:
207
+ conn_fresh.close()
208
+ except Exception:
209
+ pass
210
+ raise inner_e
211
+ raise e
212
+ except Exception:
213
+ try:
214
+ conn.close()
215
+ except Exception:
216
+ pass
217
+ raise
218
+
219
+ def can_retry(
220
+ self,
221
+ method: str,
222
+ path: str,
223
+ is_idempotent: bool = False,
224
+ idempotency_key: Optional[str] = None,
225
+ ) -> bool:
226
+ """Determine if a request is eligible for automated retries per conventions.md §8."""
227
+ is_idempotent_path = any(
228
+ path.endswith(p) or f"{p}?" in path
229
+ for p in (
230
+ "/search",
231
+ "/search:batch",
232
+ "/scroll",
233
+ "/count",
234
+ "/entities:prepare",
235
+ )
236
+ )
237
+ return bool(
238
+ is_idempotent
239
+ or is_idempotent_path
240
+ or (method.upper() == "GET")
241
+ or (idempotency_key is not None)
242
+ )
243
+
244
+ def request(
245
+ self,
246
+ method: str,
247
+ path: str,
248
+ *,
249
+ body: Optional[Any] = None,
250
+ query: Optional[Dict[str, Any]] = None,
251
+ headers: Optional[Dict[str, str]] = None,
252
+ request_id: Optional[str] = None,
253
+ idempotency_key: Optional[str] = None,
254
+ is_idempotent: bool = False,
255
+ timeout: Optional[float] = None,
256
+ auth: bool = True,
257
+ ) -> HttpResponse:
258
+ """Execute an HTTP request with headers, idempotency, and retries."""
259
+ clean_path = path if path.startswith("/") else f"/{path}"
260
+ full_path = f"{self.base_path}{clean_path}"
261
+ if query:
262
+ q_params = [(k, str(v)) for k, v in query.items() if v is not None]
263
+ if q_params:
264
+ full_path = f"{full_path}?{urllib.parse.urlencode(q_params)}"
265
+
266
+ if body is not None:
267
+ if isinstance(body, (bytes, bytearray)):
268
+ body_bytes = bytes(body)
269
+ elif isinstance(body, str):
270
+ body_bytes = body.encode("utf-8")
271
+ else:
272
+ body_bytes = json.dumps(body).encode("utf-8")
273
+ else:
274
+ body_bytes = None
275
+
276
+ req_headers: Dict[str, str] = {
277
+ "User-Agent": USER_AGENT,
278
+ "Accept": "application/json, application/problem+json",
279
+ }
280
+ req_id = request_id or uuid.uuid4().hex
281
+ req_headers["X-Request-Id"] = req_id
282
+
283
+ if auth and self.api_key:
284
+ req_headers["Authorization"] = f"Bearer {self.api_key}"
285
+
286
+ if body_bytes is not None:
287
+ req_headers["Content-Type"] = "application/json"
288
+ req_headers["Content-Length"] = str(len(body_bytes))
289
+
290
+ if idempotency_key is not None:
291
+ req_headers["Idempotency-Key"] = str(idempotency_key)
292
+
293
+ if headers:
294
+ req_headers.update(headers)
295
+
296
+ is_idempotent_path = any(
297
+ path.endswith(p) or f"{p}?" in path
298
+ for p in (
299
+ "/search",
300
+ "/search:batch",
301
+ "/scroll",
302
+ "/count",
303
+ "/entities:prepare",
304
+ )
305
+ )
306
+ can_retry = (
307
+ is_idempotent
308
+ or is_idempotent_path
309
+ or (method.upper() == "GET")
310
+ or (idempotency_key is not None)
311
+ )
312
+ effective_timeout = timeout if timeout is not None else self.timeout
313
+ attempts_limit = self.max_retries if can_retry else 0
314
+
315
+ last_retry_after: Optional[float] = None
316
+
317
+ for attempt in range(attempts_limit + 1):
318
+ if attempt > 0:
319
+ if last_retry_after is not None:
320
+ delay = last_retry_after
321
+ else:
322
+ n = attempt - 1
323
+ base_delay = 0.2 * (2.0**n)
324
+ jitter = random.uniform(0.8, 1.2)
325
+ delay = base_delay * jitter
326
+ time.sleep(delay)
327
+
328
+ try:
329
+ resp = self._send_once(
330
+ method=method.upper(),
331
+ path=full_path,
332
+ headers=req_headers,
333
+ body_bytes=body_bytes,
334
+ timeout=effective_timeout,
335
+ )
336
+ except (http.client.HTTPException, socket.error, OSError, TimeoutError) as e:
337
+ trans_err = TransportError(str(e), request_id=req_id, cause=e)
338
+ if attempt < attempts_limit:
339
+ last_retry_after = None
340
+ continue
341
+ raise trans_err from e
342
+
343
+ status = resp.status
344
+ if 200 <= status < 300:
345
+ return resp
346
+
347
+ if clean_path == "/readyz" and status == 503:
348
+ problem_data = self._parse_json(resp.body)
349
+ if isinstance(problem_data, dict) and "ready" in problem_data:
350
+ return resp
351
+
352
+ problem_dict = self._parse_json(resp.body)
353
+ err = self._map_error(
354
+ status=status,
355
+ problem=problem_dict,
356
+ request_id=resp.request_id,
357
+ resp_body=resp.body,
358
+ )
359
+
360
+ if attempt < attempts_limit and err.retryable:
361
+ retry_after_str = resp.headers.get("retry-after")
362
+ last_retry_after = self._parse_retry_after(retry_after_str)
363
+ continue
364
+
365
+ raise err
366
+
367
+ raise TransportError("Request failed after all retries", request_id=req_id)
368
+
369
+ def _parse_json(self, body: bytes) -> Optional[Dict[str, Any]]:
370
+ if not body:
371
+ return None
372
+ try:
373
+ data = json.loads(body.decode("utf-8"))
374
+ if isinstance(data, dict):
375
+ return data
376
+ return None
377
+ except Exception:
378
+ return None
379
+
380
+ def _map_error(
381
+ self,
382
+ status: int,
383
+ problem: Optional[Dict[str, Any]],
384
+ request_id: str,
385
+ resp_body: bytes,
386
+ ) -> ApiError:
387
+ if problem and ("code" in problem or "type" in problem):
388
+ return error_from_problem(problem, status=status, request_id=request_id)
389
+
390
+ retryable = status in (429, 502, 503, 504)
391
+ detail = resp_body.decode("utf-8", errors="replace") if resp_body else None
392
+ code = (
393
+ "rate_limited"
394
+ if status == 429
395
+ else ("not_ready" if status == 503 else f"http_{status}")
396
+ )
397
+ synth_problem: Dict[str, Any] = {
398
+ "type": f"urn:arkivector:error:{code}",
399
+ "title": f"HTTP {status}",
400
+ "status": status,
401
+ "code": code,
402
+ "retryable": retryable,
403
+ "detail": detail,
404
+ "request_id": request_id,
405
+ }
406
+ return error_from_problem(synth_problem, status=status, request_id=request_id)
407
+
408
+ @staticmethod
409
+ def _parse_retry_after(header_val: Optional[str]) -> Optional[float]:
410
+ if not header_val:
411
+ return None
412
+ try:
413
+ val = float(header_val.strip())
414
+ return max(0.0, val)
415
+ except ValueError:
416
+ return None
417
+
418
+ def close(self) -> None:
419
+ """Close connection pool."""
420
+ self._pool.close()