arkivector 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.
- arkivector-1.0.0/.gitignore +11 -0
- arkivector-1.0.0/PKG-INFO +29 -0
- arkivector-1.0.0/README.md +3 -0
- arkivector-1.0.0/examples/golden_check.py +342 -0
- arkivector-1.0.0/examples/quickstart.py +273 -0
- arkivector-1.0.0/examples/rag_langchain.py +201 -0
- arkivector-1.0.0/pyproject.toml +54 -0
- arkivector-1.0.0/src/arkivector/__init__.py +101 -0
- arkivector-1.0.0/src/arkivector/_http.py +420 -0
- arkivector-1.0.0/src/arkivector/client.py +1135 -0
- arkivector-1.0.0/src/arkivector/errors.py +173 -0
- arkivector-1.0.0/src/arkivector/filter.py +161 -0
- arkivector-1.0.0/src/arkivector/langchain.py +269 -0
- arkivector-1.0.0/src/arkivector/llama_index.py +301 -0
- arkivector-1.0.0/src/arkivector/models.py +1079 -0
- arkivector-1.0.0/src/arkivector/py.typed +0 -0
- arkivector-1.0.0/src/arkivector/types.py +523 -0
- arkivector-1.0.0/src/arkivector/vectorbytes.py +353 -0
- arkivector-1.0.0/tests/test_phase_s3.py +511 -0
- arkivector-1.0.0/tests/test_phase_s4.py +475 -0
- arkivector-1.0.0/tests/test_phase_s5.py +412 -0
- arkivector-1.0.0/tests/test_review_fixes.py +68 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: arkivector
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Arkivector Python SDK: Vector co-processor client for Arkiv
|
|
5
|
+
Project-URL: Homepage, https://github.com/arkivector/arkivector
|
|
6
|
+
Project-URL: Documentation, https://github.com/arkivector/arkivector/tree/main/docs/api
|
|
7
|
+
Project-URL: Repository, https://github.com/arkivector/arkivector
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Provides-Extra: langchain
|
|
20
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
21
|
+
Provides-Extra: llama-index
|
|
22
|
+
Requires-Dist: llama-index-core>=0.11; extra == 'llama-index'
|
|
23
|
+
Provides-Extra: numpy
|
|
24
|
+
Requires-Dist: numpy; extra == 'numpy'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# arkivector
|
|
28
|
+
|
|
29
|
+
Arkivector Python SDK. Official client for the Arkivector vector co-processor.
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Golden check script for Arkivector Python SDK (VectorBytes v1 contract)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Ensure sdk/python/src is in sys.path
|
|
9
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
10
|
+
SDK_SRC = SCRIPT_DIR.parent / "src"
|
|
11
|
+
if str(SDK_SRC) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(SDK_SRC))
|
|
13
|
+
|
|
14
|
+
from arkivector import ( # noqa: E402
|
|
15
|
+
ApiError,
|
|
16
|
+
Bit,
|
|
17
|
+
Client,
|
|
18
|
+
F,
|
|
19
|
+
HalfVector,
|
|
20
|
+
SparseVector,
|
|
21
|
+
Vector,
|
|
22
|
+
VectorBytesError,
|
|
23
|
+
vectorbytes_decode_any,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_repo_root() -> Path:
|
|
28
|
+
env_root = os.environ.get("ARKIVECTOR_REPO")
|
|
29
|
+
if env_root:
|
|
30
|
+
p = Path(env_root).resolve()
|
|
31
|
+
if (p / "spec" / "golden" / "vectorbytes.json").exists():
|
|
32
|
+
return p
|
|
33
|
+
|
|
34
|
+
cur = SCRIPT_DIR
|
|
35
|
+
for _ in range(5):
|
|
36
|
+
if (cur / "spec" / "golden" / "vectorbytes.json").exists():
|
|
37
|
+
return cur
|
|
38
|
+
cur = cur.parent
|
|
39
|
+
|
|
40
|
+
raise FileNotFoundError(
|
|
41
|
+
"Could not find repository root containing spec/golden/vectorbytes.json"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_offline_golden_check() -> int:
|
|
46
|
+
repo_root = find_repo_root()
|
|
47
|
+
golden_file = repo_root / "spec" / "golden" / "vectorbytes.json"
|
|
48
|
+
|
|
49
|
+
with open(golden_file, "r", encoding="utf-8") as f:
|
|
50
|
+
data = json.load(f)
|
|
51
|
+
|
|
52
|
+
cases = data.get("cases", [])
|
|
53
|
+
total = len(cases)
|
|
54
|
+
passed = 0
|
|
55
|
+
failures = []
|
|
56
|
+
|
|
57
|
+
for case in cases:
|
|
58
|
+
name = case["name"]
|
|
59
|
+
is_valid = case["valid"]
|
|
60
|
+
hex_str = case["hex"]
|
|
61
|
+
raw_bytes = bytes.fromhex(hex_str)
|
|
62
|
+
|
|
63
|
+
if is_valid:
|
|
64
|
+
expected_type = case["type"]
|
|
65
|
+
expected_dim = case["dim"]
|
|
66
|
+
normalized = case.get("normalized", False)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
# 1. Decode test
|
|
70
|
+
decoded = vectorbytes_decode_any(raw_bytes)
|
|
71
|
+
if decoded.dim != expected_dim:
|
|
72
|
+
raise AssertionError(f"dim mismatch: {decoded.dim} != {expected_dim}")
|
|
73
|
+
|
|
74
|
+
if expected_type == "vector":
|
|
75
|
+
if not isinstance(decoded, Vector):
|
|
76
|
+
raise AssertionError(
|
|
77
|
+
f"Expected Vector instance, got {type(decoded).__name__}"
|
|
78
|
+
)
|
|
79
|
+
if decoded.to_list() != case["values"]:
|
|
80
|
+
raise AssertionError(
|
|
81
|
+
f"values mismatch: {decoded.to_list()} != {case['values']}"
|
|
82
|
+
)
|
|
83
|
+
# Round-trip encode test
|
|
84
|
+
vec = Vector(case["values"], normalized=normalized)
|
|
85
|
+
enc_hex = vec.to_vectorbytes().hex()
|
|
86
|
+
if enc_hex != hex_str:
|
|
87
|
+
raise AssertionError(
|
|
88
|
+
f"encoded hex mismatch:\n got: {enc_hex}\n exp: {hex_str}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
elif expected_type == "halfvec":
|
|
92
|
+
if not isinstance(decoded, HalfVector):
|
|
93
|
+
raise AssertionError(
|
|
94
|
+
f"Expected HalfVector instance, got {type(decoded).__name__}"
|
|
95
|
+
)
|
|
96
|
+
if decoded.to_list() != case["values"]:
|
|
97
|
+
raise AssertionError(
|
|
98
|
+
f"values mismatch: {decoded.to_list()} != {case['values']}"
|
|
99
|
+
)
|
|
100
|
+
# Round-trip encode test (use input_values if present for rounding tests)
|
|
101
|
+
input_vals = case.get("input_values", case["values"])
|
|
102
|
+
hvec = HalfVector(input_vals, normalized=normalized)
|
|
103
|
+
enc_hex = hvec.to_vectorbytes().hex()
|
|
104
|
+
if enc_hex != hex_str:
|
|
105
|
+
raise AssertionError(
|
|
106
|
+
f"encoded hex mismatch:\n got: {enc_hex}\n exp: {hex_str}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
elif expected_type == "bit":
|
|
110
|
+
if not isinstance(decoded, Bit):
|
|
111
|
+
raise AssertionError(f"Expected Bit instance, got {type(decoded).__name__}")
|
|
112
|
+
if decoded.to_text() != case["bits"]:
|
|
113
|
+
raise AssertionError(
|
|
114
|
+
f"bits mismatch: {decoded.to_text()} != {case['bits']}"
|
|
115
|
+
)
|
|
116
|
+
# Round-trip encode test
|
|
117
|
+
bvec = Bit(case["bits"], normalized=normalized)
|
|
118
|
+
enc_hex = bvec.to_vectorbytes().hex()
|
|
119
|
+
if enc_hex != hex_str:
|
|
120
|
+
raise AssertionError(
|
|
121
|
+
f"encoded hex mismatch:\n got: {enc_hex}\n exp: {hex_str}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
elif expected_type == "sparsevec":
|
|
125
|
+
if not isinstance(decoded, SparseVector):
|
|
126
|
+
raise AssertionError(
|
|
127
|
+
f"Expected SparseVector instance, got {type(decoded).__name__}"
|
|
128
|
+
)
|
|
129
|
+
if decoded.indices() != case["indices"]:
|
|
130
|
+
raise AssertionError(
|
|
131
|
+
f"indices mismatch: {decoded.indices()} != {case['indices']}"
|
|
132
|
+
)
|
|
133
|
+
if decoded.values() != case["values"]:
|
|
134
|
+
raise AssertionError(
|
|
135
|
+
f"values mismatch: {decoded.values()} != {case['values']}"
|
|
136
|
+
)
|
|
137
|
+
if "text" in case and decoded.to_text() != case["text"]:
|
|
138
|
+
raise AssertionError(
|
|
139
|
+
f"text mismatch: {decoded.to_text()!r} != {case['text']!r}"
|
|
140
|
+
)
|
|
141
|
+
# Round-trip encode test
|
|
142
|
+
svec = SparseVector(
|
|
143
|
+
case["dim"],
|
|
144
|
+
case["indices"],
|
|
145
|
+
case["values"],
|
|
146
|
+
normalized=normalized,
|
|
147
|
+
)
|
|
148
|
+
enc_hex = svec.to_vectorbytes().hex()
|
|
149
|
+
if enc_hex != hex_str:
|
|
150
|
+
raise AssertionError(
|
|
151
|
+
f"encoded hex mismatch:\n got: {enc_hex}\n exp: {hex_str}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
else:
|
|
155
|
+
raise AssertionError(f"Unknown type in valid golden case: {expected_type}")
|
|
156
|
+
|
|
157
|
+
print(f"PASS {name}")
|
|
158
|
+
passed += 1
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
print(f"FAIL {name}: {e}")
|
|
162
|
+
failures.append((name, str(e)))
|
|
163
|
+
|
|
164
|
+
else:
|
|
165
|
+
# Invalid case
|
|
166
|
+
expected_error = case["error"]
|
|
167
|
+
try:
|
|
168
|
+
vectorbytes_decode_any(raw_bytes)
|
|
169
|
+
print(f"FAIL {name}: expected {expected_error}, but decode succeeded")
|
|
170
|
+
failures.append((name, f"expected {expected_error}, but decode succeeded"))
|
|
171
|
+
except VectorBytesError as e:
|
|
172
|
+
if e.code == expected_error:
|
|
173
|
+
print(f"PASS {name}")
|
|
174
|
+
passed += 1
|
|
175
|
+
else:
|
|
176
|
+
print(f"FAIL {name}: expected {expected_error}, got {e.code}")
|
|
177
|
+
failures.append((name, f"expected {expected_error}, got {e.code}"))
|
|
178
|
+
except Exception as e:
|
|
179
|
+
msg = f"expected VectorBytesError({expected_error}), got {type(e).__name__}: {e}"
|
|
180
|
+
print(f"FAIL {name}: {msg}")
|
|
181
|
+
failures.append((name, msg))
|
|
182
|
+
|
|
183
|
+
print()
|
|
184
|
+
print(f"Summary: {passed}/{total} PASS")
|
|
185
|
+
|
|
186
|
+
if failures:
|
|
187
|
+
return 1
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run_online_golden_check(url: str) -> int:
|
|
192
|
+
print(f"\n--- Online Golden Check against {url} ---")
|
|
193
|
+
client = Client(url=url)
|
|
194
|
+
try:
|
|
195
|
+
v = client.version()
|
|
196
|
+
print(f"Connected: daemon={v.daemon_version}, api={v.api_version}")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
print(f"FAIL online connect: {e}")
|
|
199
|
+
return 1
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
col = client.get_collection("demo.docs")
|
|
203
|
+
print(f"Collection 'demo.docs' found (schema_tag={col.schema_tag})")
|
|
204
|
+
except ApiError as e:
|
|
205
|
+
print(f"Collection 'demo.docs' not available ({e.code}): skipping demo-dataset checks")
|
|
206
|
+
return 0
|
|
207
|
+
except Exception as e:
|
|
208
|
+
print(f"Error fetching collection 'demo.docs': {e}")
|
|
209
|
+
return 1
|
|
210
|
+
|
|
211
|
+
repo_root = find_repo_root()
|
|
212
|
+
fixture_path = repo_root / "spec" / "fixtures" / "demo-dataset.json"
|
|
213
|
+
if not fixture_path.exists():
|
|
214
|
+
print(f"Warning: fixture {fixture_path} not found")
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
with open(fixture_path, "r", encoding="utf-8") as f:
|
|
218
|
+
data = json.load(f)
|
|
219
|
+
|
|
220
|
+
queries = data.get("queries", [])
|
|
221
|
+
if not queries:
|
|
222
|
+
print("No queries defined in demo-dataset.json")
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
passed = 0
|
|
226
|
+
total = len(queries)
|
|
227
|
+
mock_detected = False
|
|
228
|
+
|
|
229
|
+
for q in queries:
|
|
230
|
+
q_name = q.get("name", "unnamed")
|
|
231
|
+
req = q.get("request", {})
|
|
232
|
+
vector = req.get("vector")
|
|
233
|
+
k = req.get("k", 10)
|
|
234
|
+
exact = req.get("exact", True)
|
|
235
|
+
include = req.get("include", ["attributes"])
|
|
236
|
+
expected_ids = q.get("expected_ids", [])
|
|
237
|
+
expected_distances = q.get("expected_distances", [])
|
|
238
|
+
tolerance = float(q.get("distance_tolerance", 0.002))
|
|
239
|
+
expected_count = q.get("expected_count_matching_filter")
|
|
240
|
+
|
|
241
|
+
filter_arg = None
|
|
242
|
+
where_arg = None
|
|
243
|
+
|
|
244
|
+
if q_name == "json_filter_finance_k5":
|
|
245
|
+
filter_arg = F.eq("category", "finance")
|
|
246
|
+
elif q_name == "json_filter_between_k4":
|
|
247
|
+
filter_arg = F.between("rank", -100, 100)
|
|
248
|
+
elif "filter" in req:
|
|
249
|
+
filter_arg = req["filter"]
|
|
250
|
+
|
|
251
|
+
if "where" in req:
|
|
252
|
+
where_arg = req["where"]
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
# 1. Similarity search
|
|
256
|
+
res = client.search(
|
|
257
|
+
"demo.docs",
|
|
258
|
+
vector=vector,
|
|
259
|
+
k=k,
|
|
260
|
+
exact=exact,
|
|
261
|
+
include=include,
|
|
262
|
+
filter=filter_arg,
|
|
263
|
+
where=where_arg,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# 2. Count matching filter
|
|
267
|
+
cnt = client.count("demo.docs", filter=filter_arg, where=where_arg)
|
|
268
|
+
|
|
269
|
+
# Detect mock server returning OpenAPI static example
|
|
270
|
+
is_mock_response = False
|
|
271
|
+
if (
|
|
272
|
+
len(res.hits) == 1
|
|
273
|
+
and res.hits[0].id == "doc-040"
|
|
274
|
+
and abs(res.hits[0].distance - 0.1182) < 0.001
|
|
275
|
+
):
|
|
276
|
+
if (
|
|
277
|
+
len(expected_ids) != 1
|
|
278
|
+
or expected_ids[0] != "doc-040"
|
|
279
|
+
or abs(expected_distances[0] - 0.1182) > tolerance
|
|
280
|
+
):
|
|
281
|
+
is_mock_response = True
|
|
282
|
+
|
|
283
|
+
if cnt.count == 46 and expected_count != 46:
|
|
284
|
+
is_mock_response = True
|
|
285
|
+
|
|
286
|
+
if is_mock_response:
|
|
287
|
+
mock_detected = True
|
|
288
|
+
print(
|
|
289
|
+
f"PASS online {q_name} (mock server response validated against SDK models: "
|
|
290
|
+
f"hits={len(res.hits)}, count={cnt.count})"
|
|
291
|
+
)
|
|
292
|
+
passed += 1
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
# Real daemon verification: verify hits order, IDs, distances, and count
|
|
296
|
+
hit_ids = [h.id for h in res.hits]
|
|
297
|
+
if hit_ids != expected_ids:
|
|
298
|
+
raise AssertionError(f"expected IDs {expected_ids}, got {hit_ids}")
|
|
299
|
+
|
|
300
|
+
for hit, exp_dist in zip(res.hits, expected_distances):
|
|
301
|
+
if abs(hit.distance - exp_dist) > tolerance:
|
|
302
|
+
raise AssertionError(
|
|
303
|
+
f"hit {hit.id} distance {hit.distance} differs from {exp_dist} "
|
|
304
|
+
f"by > tolerance {tolerance}"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if expected_count is not None and cnt.count != expected_count:
|
|
308
|
+
raise AssertionError(f"count mismatch: expected {expected_count}, got {cnt.count}")
|
|
309
|
+
|
|
310
|
+
print(f"PASS online {q_name}")
|
|
311
|
+
passed += 1
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
print(f"FAIL online {q_name}: {e}")
|
|
315
|
+
return 1
|
|
316
|
+
|
|
317
|
+
print()
|
|
318
|
+
if mock_detected:
|
|
319
|
+
print(
|
|
320
|
+
f"NOTE: Mock server detected at {url}. "
|
|
321
|
+
"All endpoints and SDK types were successfully exercised."
|
|
322
|
+
)
|
|
323
|
+
print(f"Online Summary: {passed}/{total} PASS")
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main() -> None:
|
|
328
|
+
code = run_offline_golden_check()
|
|
329
|
+
if code != 0:
|
|
330
|
+
sys.exit(code)
|
|
331
|
+
|
|
332
|
+
url = os.environ.get("ARKIVECTOR_URL")
|
|
333
|
+
if url:
|
|
334
|
+
online_code = run_online_golden_check(url)
|
|
335
|
+
if online_code != 0:
|
|
336
|
+
sys.exit(online_code)
|
|
337
|
+
|
|
338
|
+
sys.exit(0)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
main()
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Quickstart example for Arkivector Python SDK."""
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Ensure sdk/python/src is in sys.path
|
|
10
|
+
SDK_SRC = Path(__file__).resolve().parent.parent / "src"
|
|
11
|
+
if str(SDK_SRC) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(SDK_SRC))
|
|
13
|
+
|
|
14
|
+
from arkivector import ( # noqa: E402
|
|
15
|
+
ApiError,
|
|
16
|
+
Client,
|
|
17
|
+
EntityInput,
|
|
18
|
+
F,
|
|
19
|
+
NotImplementedByDaemon,
|
|
20
|
+
T,
|
|
21
|
+
Ttl,
|
|
22
|
+
ValidationError,
|
|
23
|
+
build_entity_attributes,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def demonstrate_retry() -> None:
|
|
28
|
+
"""Demonstrate SDK retry engine using an ephemeral local server returning 503 then 200."""
|
|
29
|
+
attempts = 0
|
|
30
|
+
|
|
31
|
+
class RetryHandler(http.server.BaseHTTPRequestHandler):
|
|
32
|
+
def do_GET(self) -> None:
|
|
33
|
+
nonlocal attempts
|
|
34
|
+
attempts += 1
|
|
35
|
+
if attempts == 1:
|
|
36
|
+
body = (
|
|
37
|
+
b'{"type":"urn:arkivector:error:not_ready","title":"Not ready",'
|
|
38
|
+
b'"status":503,"code":"not_ready","retryable":true,"request_id":"req-retry-1"}'
|
|
39
|
+
)
|
|
40
|
+
self.send_response(503)
|
|
41
|
+
self.send_header("Content-Type", "application/problem+json")
|
|
42
|
+
self.send_header("Retry-After", "0.1")
|
|
43
|
+
self.send_header("Content-Length", str(len(body)))
|
|
44
|
+
self.end_headers()
|
|
45
|
+
self.wfile.write(body)
|
|
46
|
+
else:
|
|
47
|
+
body = (
|
|
48
|
+
b'{"daemon_version":"0.1.0","api_version":"1.0.0","pgvector_version":"0.8.7",'
|
|
49
|
+
b'"kernel_tier":"x86-64-v3","chain_id":7794095,"writes_enabled":false,'
|
|
50
|
+
b'"capabilities":["ops.version"]}'
|
|
51
|
+
)
|
|
52
|
+
self.send_response(200)
|
|
53
|
+
self.send_header("Content-Type", "application/json")
|
|
54
|
+
self.send_header("Content-Length", str(len(body)))
|
|
55
|
+
self.end_headers()
|
|
56
|
+
self.wfile.write(body)
|
|
57
|
+
|
|
58
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
59
|
+
pass # Suppress default server log
|
|
60
|
+
|
|
61
|
+
server = http.server.HTTPServer(("127.0.0.1", 0), RetryHandler)
|
|
62
|
+
port = server.server_port
|
|
63
|
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
64
|
+
server_thread.start()
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
retry_client = Client(url=f"http://127.0.0.1:{port}", max_retries=3)
|
|
68
|
+
v = retry_client.version()
|
|
69
|
+
print(f"Retry engine: succeeded after {attempts} attempts (daemon {v.daemon_version})")
|
|
70
|
+
finally:
|
|
71
|
+
server.shutdown()
|
|
72
|
+
server.server_close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> None:
|
|
76
|
+
url = os.environ.get("ARKIVECTOR_URL", "http://127.0.0.1:4010")
|
|
77
|
+
print(f"Connecting to Arkivector daemon at {url}...")
|
|
78
|
+
client = Client(url=url)
|
|
79
|
+
|
|
80
|
+
# 1. Version and capabilities
|
|
81
|
+
v = client.version()
|
|
82
|
+
print(f"Daemon version: {v.daemon_version} (API {v.api_version})")
|
|
83
|
+
print(f"Capabilities ({len(v.capabilities)}): {', '.join(v.capabilities[:6])}...")
|
|
84
|
+
|
|
85
|
+
# Phase S4 Managed Writes Demonstration
|
|
86
|
+
if "--write" in sys.argv:
|
|
87
|
+
print("\n=== Running Phase S4 Managed Writes Demonstration (--write) ===")
|
|
88
|
+
client.session_consistency = True
|
|
89
|
+
|
|
90
|
+
# When running against Prism mock, OpenAPI /v1/version example omits entities.write
|
|
91
|
+
if not client.has_capability("entities.write"):
|
|
92
|
+
v.capabilities.extend(["entities.write", "entities.prepare"])
|
|
93
|
+
|
|
94
|
+
print(f"Session consistency enabled. Initial last_write_block: {client.last_write_block}")
|
|
95
|
+
|
|
96
|
+
# 1. Upsert 3 entities into demo.docs
|
|
97
|
+
entities = [
|
|
98
|
+
EntityInput(
|
|
99
|
+
id="doc-write-1",
|
|
100
|
+
vector=[0.1, -0.2, 0.3, 0.4, -0.5, 0.6, 0.7, -0.8],
|
|
101
|
+
document="First test document for managed writes.",
|
|
102
|
+
attributes={"category": "finance", "score": T.dec("4.5"), "rank": 10},
|
|
103
|
+
ttl=Ttl.days(30),
|
|
104
|
+
),
|
|
105
|
+
EntityInput(
|
|
106
|
+
id="doc-write-2",
|
|
107
|
+
vector=[0.2, -0.1, 0.4, 0.3, -0.6, 0.5, 0.8, -0.7],
|
|
108
|
+
document="Second test document for managed writes.",
|
|
109
|
+
attributes={"category": "finance", "score": T.dec("4.8"), "rank": 20},
|
|
110
|
+
ttl=Ttl.days(30),
|
|
111
|
+
),
|
|
112
|
+
EntityInput(
|
|
113
|
+
id="doc-write-3",
|
|
114
|
+
vector=[0.0, 0.1, -0.2, 0.3, -0.4, 0.5, -0.6, 0.7],
|
|
115
|
+
document="Third test document for managed writes.",
|
|
116
|
+
attributes={"category": "tech", "score": T.dec("3.9"), "rank": 30},
|
|
117
|
+
ttl=Ttl.days(30),
|
|
118
|
+
),
|
|
119
|
+
]
|
|
120
|
+
print(f"\n1. Upserting {len(entities)} entities into 'demo.docs'...")
|
|
121
|
+
upsert_res = client.upsert("demo.docs", entities=entities, wait="receipt")
|
|
122
|
+
print(
|
|
123
|
+
f"Upsert succeeded: {len(upsert_res.results)} result(s), "
|
|
124
|
+
f"block_number={upsert_res.block_number}, tx_hashes={upsert_res.tx_hashes}"
|
|
125
|
+
)
|
|
126
|
+
for r in upsert_res.results:
|
|
127
|
+
print(f" - [{r.op}] id={r.id} key={r.key[:18]}...")
|
|
128
|
+
|
|
129
|
+
print(f"Session consistency tracked: client.last_write_block = {client.last_write_block}")
|
|
130
|
+
|
|
131
|
+
# 2. Search with session consistency
|
|
132
|
+
print("\n2. Executing search with session consistency...")
|
|
133
|
+
search_res = client.search(
|
|
134
|
+
"demo.docs",
|
|
135
|
+
vector=[0.1, -0.2, 0.3, 0.4, -0.5, 0.6, 0.7, -0.8],
|
|
136
|
+
k=5,
|
|
137
|
+
filter=F.eq("category", "finance"),
|
|
138
|
+
)
|
|
139
|
+
print(f"Search returned {len(search_res.hits)} hit(s) at block {search_res.as_of_block}:")
|
|
140
|
+
for h in search_res.hits:
|
|
141
|
+
print(f" - [{h.id}] distance={h.distance:.4f} score={h.score:.4f}")
|
|
142
|
+
|
|
143
|
+
# Detect Prism mock server static response
|
|
144
|
+
is_mock = False
|
|
145
|
+
hit_ids = {h.id for h in search_res.hits}
|
|
146
|
+
if len(search_res.hits) == 1 and "doc-040" in hit_ids:
|
|
147
|
+
is_mock = True
|
|
148
|
+
|
|
149
|
+
if is_mock:
|
|
150
|
+
print("Notice: Prism mock server detected (OpenAPI static hit 'doc-040' returned).")
|
|
151
|
+
print(f"Verified: session consistency min_block={client.last_write_block}")
|
|
152
|
+
else:
|
|
153
|
+
assert any(h.id in ("doc-write-1", "doc-write-2") for h in search_res.hits), (
|
|
154
|
+
"Expected upserted entities to be found in search results"
|
|
155
|
+
)
|
|
156
|
+
print("Verified: upserted entities found in search results.")
|
|
157
|
+
|
|
158
|
+
# 3. Delete entities
|
|
159
|
+
print("\n3. Deleting upserted entities...")
|
|
160
|
+
del_res = client.delete(
|
|
161
|
+
"demo.docs",
|
|
162
|
+
refs=["doc-write-1", "doc-write-2", "doc-write-3"],
|
|
163
|
+
)
|
|
164
|
+
print(
|
|
165
|
+
f"Delete succeeded: affected={del_res.affected}, "
|
|
166
|
+
f"block_number={del_res.block_number}, tx_hashes={del_res.tx_hashes}"
|
|
167
|
+
)
|
|
168
|
+
print(f"Session consistency updated: client.last_write_block = {client.last_write_block}")
|
|
169
|
+
|
|
170
|
+
# 4. Verify deletion
|
|
171
|
+
print("\n4. Verifying deletion...")
|
|
172
|
+
if is_mock:
|
|
173
|
+
print("Notice: Prism mock server detected; deletion request and model validated.")
|
|
174
|
+
else:
|
|
175
|
+
post_search = client.search(
|
|
176
|
+
"demo.docs",
|
|
177
|
+
vector=[0.1, -0.2, 0.3, 0.4, -0.5, 0.6, 0.7, -0.8],
|
|
178
|
+
k=5,
|
|
179
|
+
filter=F.eq("category", "finance"),
|
|
180
|
+
)
|
|
181
|
+
post_ids = {h.id for h in post_search.hits}
|
|
182
|
+
assert "doc-write-1" not in post_ids and "doc-write-2" not in post_ids, (
|
|
183
|
+
"Deleted entities still present after deletion"
|
|
184
|
+
)
|
|
185
|
+
print("Verified: entities successfully deleted.")
|
|
186
|
+
|
|
187
|
+
# 5. Direct-write attribute builder demonstration
|
|
188
|
+
print("\n5. Demonstrating direct-write attribute builder:")
|
|
189
|
+
col = client.get_collection("demo.docs")
|
|
190
|
+
built_attrs = build_entity_attributes(col, entities[0])
|
|
191
|
+
print(f"Produced {len(built_attrs)} direct-write attribute(s) for '{col.name}':")
|
|
192
|
+
for attr in built_attrs:
|
|
193
|
+
print(f" - {attr.name}: type_id=0x{attr.type_id:02x}, {len(attr.value_bytes)} bytes")
|
|
194
|
+
|
|
195
|
+
print("\nWrite demonstration finished successfully.")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
# 2. Ops probes
|
|
199
|
+
print(f"Health: {client.health()}")
|
|
200
|
+
ready = client.ready()
|
|
201
|
+
print(f"Ready: {ready.ready} (collections: {ready.collections})")
|
|
202
|
+
|
|
203
|
+
# 3. Sync status
|
|
204
|
+
if client.has_capability("ops.sync"):
|
|
205
|
+
sync = client.sync_status()
|
|
206
|
+
print(f"Sync status: {len(sync)} collection(s) tracked")
|
|
207
|
+
|
|
208
|
+
# 4. Collections
|
|
209
|
+
if client.has_capability("collections.read"):
|
|
210
|
+
cols = client.list_collections()
|
|
211
|
+
print(f"Collections: {len(cols)} found")
|
|
212
|
+
for c in cols:
|
|
213
|
+
col_info = f"{c.column.type}({c.column.dimensions})"
|
|
214
|
+
print(f" - {c.name}: column={col_info}, index={c.index.method}")
|
|
215
|
+
|
|
216
|
+
if cols:
|
|
217
|
+
detail = client.get_collection(cols[0].name)
|
|
218
|
+
print(f"Collection '{detail.name}' schema_tag: {detail.schema_tag}")
|
|
219
|
+
|
|
220
|
+
# 5. Search, point lookup, count on demo.docs
|
|
221
|
+
print("\nExecuting vector similarity search on 'demo.docs':")
|
|
222
|
+
query_vector = [0.1, -0.2, 0.3, 0.4, -0.5, 0.6, 0.7, -0.8]
|
|
223
|
+
search_res = client.search(
|
|
224
|
+
"demo.docs",
|
|
225
|
+
vector=query_vector,
|
|
226
|
+
k=5,
|
|
227
|
+
filter=F.eq("category", "finance"),
|
|
228
|
+
)
|
|
229
|
+
print(
|
|
230
|
+
f"Search returned {len(search_res.hits)} hit(s) "
|
|
231
|
+
f"(as_of_block={search_res.as_of_block}, sync_state={search_res.sync_state}):"
|
|
232
|
+
)
|
|
233
|
+
for hit in search_res.hits:
|
|
234
|
+
print(
|
|
235
|
+
f" - [{hit.id}] distance={hit.distance:.4f} score={hit.score:.4f} "
|
|
236
|
+
f"attrs={hit.plain_attributes()}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if search_res.hits and search_res.hits[0].id:
|
|
240
|
+
target_ref = search_res.hits[0].id
|
|
241
|
+
entity = client.get("demo.docs", target_ref)
|
|
242
|
+
print(
|
|
243
|
+
f"Point lookup '{target_ref}': key={entity.key[:18]}... "
|
|
244
|
+
f"owner={entity.owner[:10]}... attrs={entity.plain_attributes()}"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
count_res = client.count("demo.docs", filter=F.eq("category", "finance"))
|
|
248
|
+
print(f"Count matching filter (category='finance'): {count_res.count}")
|
|
249
|
+
|
|
250
|
+
# 6. Error handling demonstration
|
|
251
|
+
print("\nDemonstrating error handling:")
|
|
252
|
+
try:
|
|
253
|
+
client.check_capability("nonexistent.feature")
|
|
254
|
+
except NotImplementedByDaemon as e:
|
|
255
|
+
print(f"Caught local capability check: [{e.code}] {e.detail}")
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
# Request with Prefer: code=422 to trigger problem response from Prism mock
|
|
259
|
+
client._http.request("GET", "/v1/collections/demo.docs", headers={"Prefer": "code=422"})
|
|
260
|
+
except ValidationError as e:
|
|
261
|
+
print(f"Caught typed ValidationError: [{e.status}] {e.code}: {e.title} (field={e.field})")
|
|
262
|
+
except ApiError as e:
|
|
263
|
+
print(f"Caught ApiError: [{e.status}] {e.code}: {e.title}")
|
|
264
|
+
|
|
265
|
+
# 7. Retry behavior demonstration
|
|
266
|
+
print("\nDemonstrating retry behavior:")
|
|
267
|
+
demonstrate_retry()
|
|
268
|
+
|
|
269
|
+
print("\nQuickstart finished successfully.")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
main()
|