vecspan 0.1.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.
- vecspan/__init__.py +50 -0
- vecspan/backends/__init__.py +6 -0
- vecspan/backends/base.py +107 -0
- vecspan/backends/memory.py +289 -0
- vecspan/backends/milvus.py +696 -0
- vecspan/backends/qdrant.py +532 -0
- vecspan/capabilities.py +16 -0
- vecspan/client.py +205 -0
- vecspan/errors.py +27 -0
- vecspan/filters.py +258 -0
- vecspan/naming.py +35 -0
- vecspan/py.typed +0 -0
- vecspan/registry.py +67 -0
- vecspan/types.py +282 -0
- vecspan/validate.py +348 -0
- vecspan-0.1.0.dist-info/METADATA +137 -0
- vecspan-0.1.0.dist-info/RECORD +19 -0
- vecspan-0.1.0.dist-info/WHEEL +5 -0
- vecspan-0.1.0.dist-info/top_level.txt +1 -0
vecspan/types.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Public data types shared by every backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
from vecspan.errors import ValidationError
|
|
10
|
+
from vecspan.naming import check_collection_name, check_field_name
|
|
11
|
+
|
|
12
|
+
INT64_MAX = 2**63 - 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Distance(str, Enum):
|
|
16
|
+
"""How a vector field compares two vectors.
|
|
17
|
+
|
|
18
|
+
Cosine and dot scores are larger when the vectors are closer. L2 scores
|
|
19
|
+
are smaller when they are closer.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
COSINE = "cosine"
|
|
23
|
+
DOT = "dot"
|
|
24
|
+
L2 = "l2"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IdType(str, Enum):
|
|
28
|
+
"""Primary key type chosen when a collection is created."""
|
|
29
|
+
|
|
30
|
+
INT = "int"
|
|
31
|
+
UUID = "uuid"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PayloadType(str, Enum):
|
|
35
|
+
"""Declared scalar type of a payload field."""
|
|
36
|
+
|
|
37
|
+
KEYWORD = "keyword"
|
|
38
|
+
INT = "int"
|
|
39
|
+
FLOAT = "float"
|
|
40
|
+
BOOL = "bool"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _as_enum(enum_cls: type[Enum], value: object, label: str) -> Enum:
|
|
44
|
+
if isinstance(value, enum_cls):
|
|
45
|
+
return value
|
|
46
|
+
try:
|
|
47
|
+
return enum_cls(value)
|
|
48
|
+
except ValueError:
|
|
49
|
+
allowed = ", ".join(item.value for item in enum_cls)
|
|
50
|
+
raise ValidationError(f"invalid {label}: {value!r} (expected one of {allowed})") from None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _as_positive_int(value: object, label: str) -> int:
|
|
54
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
55
|
+
raise ValidationError(f"{label} must be a positive integer")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class VectorField:
|
|
61
|
+
"""A named dense vector stored on every point."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
size: int
|
|
65
|
+
distance: Distance = Distance.COSINE
|
|
66
|
+
m: int = 16
|
|
67
|
+
ef_construct: int = 200
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
object.__setattr__(self, "name", check_field_name(self.name))
|
|
71
|
+
object.__setattr__(self, "size", _as_positive_int(self.size, "vector size"))
|
|
72
|
+
object.__setattr__(self, "distance", _as_enum(Distance, self.distance, "distance"))
|
|
73
|
+
object.__setattr__(self, "m", _as_positive_int(self.m, "m"))
|
|
74
|
+
if self.m < 2:
|
|
75
|
+
raise ValidationError("m must be at least 2")
|
|
76
|
+
object.__setattr__(self, "ef_construct", _as_positive_int(self.ef_construct, "ef_construct"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class PayloadField:
|
|
81
|
+
"""A scalar payload field. Indexed fields can be filtered efficiently."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
type: PayloadType
|
|
85
|
+
indexed: bool = True
|
|
86
|
+
max_length: int = 2048
|
|
87
|
+
|
|
88
|
+
def __post_init__(self) -> None:
|
|
89
|
+
object.__setattr__(self, "name", check_field_name(self.name))
|
|
90
|
+
object.__setattr__(self, "type", _as_enum(PayloadType, self.type, "payload type"))
|
|
91
|
+
if not isinstance(self.indexed, bool):
|
|
92
|
+
raise ValidationError("indexed must be a bool")
|
|
93
|
+
object.__setattr__(self, "max_length", _as_positive_int(self.max_length, "max_length"))
|
|
94
|
+
if self.max_length > 65535:
|
|
95
|
+
raise ValidationError("max_length must be at most 65535")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class CollectionSpec:
|
|
100
|
+
"""Schema declared when a collection is created and then left unchanged."""
|
|
101
|
+
|
|
102
|
+
name: str
|
|
103
|
+
id_type: IdType
|
|
104
|
+
vectors: Sequence[VectorField]
|
|
105
|
+
payload_fields: Sequence[PayloadField] = ()
|
|
106
|
+
|
|
107
|
+
def __post_init__(self) -> None:
|
|
108
|
+
object.__setattr__(self, "name", check_collection_name(self.name))
|
|
109
|
+
object.__setattr__(self, "id_type", _as_enum(IdType, self.id_type, "id_type"))
|
|
110
|
+
object.__setattr__(self, "vectors", _as_fields(self.vectors, VectorField, "vectors"))
|
|
111
|
+
object.__setattr__(
|
|
112
|
+
self,
|
|
113
|
+
"payload_fields",
|
|
114
|
+
_as_fields(self.payload_fields, PayloadField, "payload_fields"),
|
|
115
|
+
)
|
|
116
|
+
if not self.vectors:
|
|
117
|
+
raise ValidationError("a collection needs at least one vector field")
|
|
118
|
+
names = [field.name for field in self.vectors]
|
|
119
|
+
names.extend(field.name for field in self.payload_fields)
|
|
120
|
+
if len(names) != len(set(names)):
|
|
121
|
+
raise ValidationError("field names must be unique across vectors and payload")
|
|
122
|
+
|
|
123
|
+
def vector(self, name: str | None) -> VectorField:
|
|
124
|
+
"""Return the named vector field, or the only one when `name` is omitted."""
|
|
125
|
+
if name is None:
|
|
126
|
+
if len(self.vectors) != 1:
|
|
127
|
+
raise ValidationError("vector_name is required when the collection has several vectors")
|
|
128
|
+
return self.vectors[0]
|
|
129
|
+
for item in self.vectors:
|
|
130
|
+
if item.name == name:
|
|
131
|
+
return item
|
|
132
|
+
raise ValidationError(f"unknown vector field: {name!r}")
|
|
133
|
+
|
|
134
|
+
def payload(self, name: str) -> PayloadField | None:
|
|
135
|
+
"""Return the declared payload field, or None when the key is dynamic."""
|
|
136
|
+
for item in self.payload_fields:
|
|
137
|
+
if item.name == name:
|
|
138
|
+
return item
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _as_fields(value: object, kind: type, label: str) -> tuple[Any, ...]:
|
|
143
|
+
if value is None:
|
|
144
|
+
return ()
|
|
145
|
+
if isinstance(value, kind):
|
|
146
|
+
raise ValidationError(f"{label} must be a sequence")
|
|
147
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
|
148
|
+
raise ValidationError(f"{label} must be a sequence")
|
|
149
|
+
items = tuple(value)
|
|
150
|
+
for item in items:
|
|
151
|
+
if not isinstance(item, kind):
|
|
152
|
+
raise ValidationError(f"{label} must contain {kind.__name__} values")
|
|
153
|
+
return items
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Point:
|
|
158
|
+
"""One record: an id, one or more named vectors, and a payload mapping."""
|
|
159
|
+
|
|
160
|
+
id: int | str
|
|
161
|
+
vectors: Mapping[str, Sequence[float]]
|
|
162
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
163
|
+
|
|
164
|
+
def __post_init__(self) -> None:
|
|
165
|
+
if not isinstance(self.vectors, Mapping):
|
|
166
|
+
raise ValidationError("vectors must be a mapping of field name to vector")
|
|
167
|
+
if self.payload is None:
|
|
168
|
+
self.payload = {}
|
|
169
|
+
elif not isinstance(self.payload, Mapping):
|
|
170
|
+
raise ValidationError("payload must be a mapping")
|
|
171
|
+
else:
|
|
172
|
+
self.payload = dict(self.payload)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class SearchHit:
|
|
177
|
+
"""One neighbor returned by `search`.
|
|
178
|
+
|
|
179
|
+
`score` is the backend's native value. It is not rescaled across backends.
|
|
180
|
+
Memory and Qdrant report plain Euclidean distance for L2. Milvus reports
|
|
181
|
+
squared Euclidean distance for L2.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
id: int | str
|
|
185
|
+
score: float
|
|
186
|
+
distance: Distance
|
|
187
|
+
payload: dict[str, Any]
|
|
188
|
+
vectors: dict[str, list[float]] | None = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass(frozen=True)
|
|
192
|
+
class ScrollPage:
|
|
193
|
+
"""A page of points. Pass `next_offset` back to `scroll` to continue.
|
|
194
|
+
|
|
195
|
+
The offset is opaque and only meaningful for the same collection on the
|
|
196
|
+
same backend.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
points: list[Point]
|
|
200
|
+
next_offset: int | str | None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def spec_to_dict(spec: CollectionSpec) -> dict[str, Any]:
|
|
204
|
+
"""Serialize a spec so a backend can store it next to the collection."""
|
|
205
|
+
return {
|
|
206
|
+
"name": spec.name,
|
|
207
|
+
"id_type": spec.id_type.value,
|
|
208
|
+
"vectors": [
|
|
209
|
+
{
|
|
210
|
+
"name": item.name,
|
|
211
|
+
"size": item.size,
|
|
212
|
+
"distance": item.distance.value,
|
|
213
|
+
"m": item.m,
|
|
214
|
+
"ef_construct": item.ef_construct,
|
|
215
|
+
}
|
|
216
|
+
for item in spec.vectors
|
|
217
|
+
],
|
|
218
|
+
"payload_fields": [
|
|
219
|
+
{
|
|
220
|
+
"name": item.name,
|
|
221
|
+
"type": item.type.value,
|
|
222
|
+
"indexed": item.indexed,
|
|
223
|
+
"max_length": item.max_length,
|
|
224
|
+
}
|
|
225
|
+
for item in spec.payload_fields
|
|
226
|
+
],
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def spec_from_dict(data: Mapping[str, Any]) -> CollectionSpec:
|
|
231
|
+
"""Rebuild a spec previously produced by `spec_to_dict`."""
|
|
232
|
+
try:
|
|
233
|
+
vectors = [
|
|
234
|
+
VectorField(
|
|
235
|
+
name=item["name"],
|
|
236
|
+
size=item["size"],
|
|
237
|
+
distance=item["distance"],
|
|
238
|
+
m=item["m"],
|
|
239
|
+
ef_construct=item["ef_construct"],
|
|
240
|
+
)
|
|
241
|
+
for item in data["vectors"]
|
|
242
|
+
]
|
|
243
|
+
payload_fields = [
|
|
244
|
+
PayloadField(
|
|
245
|
+
name=item["name"],
|
|
246
|
+
type=item["type"],
|
|
247
|
+
indexed=item["indexed"],
|
|
248
|
+
max_length=item["max_length"],
|
|
249
|
+
)
|
|
250
|
+
for item in data.get("payload_fields", ())
|
|
251
|
+
]
|
|
252
|
+
return CollectionSpec(
|
|
253
|
+
name=data["name"],
|
|
254
|
+
id_type=data["id_type"],
|
|
255
|
+
vectors=vectors,
|
|
256
|
+
payload_fields=payload_fields,
|
|
257
|
+
)
|
|
258
|
+
except (KeyError, TypeError) as exc:
|
|
259
|
+
raise ValidationError(f"stored collection spec is invalid: {exc}") from exc
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def as_plain(value: Any) -> Any:
|
|
263
|
+
"""Convert numpy scalars and arrays into plain Python values."""
|
|
264
|
+
if isinstance(value, dict):
|
|
265
|
+
return {str(key): as_plain(item) for key, item in value.items()}
|
|
266
|
+
if isinstance(value, (list, tuple)):
|
|
267
|
+
return [as_plain(item) for item in value]
|
|
268
|
+
tolist = getattr(value, "tolist", None)
|
|
269
|
+
if callable(tolist) and not isinstance(value, (str, bytes)):
|
|
270
|
+
return as_plain(tolist())
|
|
271
|
+
return value
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def as_vector(value: Any) -> list[float]:
|
|
275
|
+
"""Return a vector as a list of floats."""
|
|
276
|
+
data = as_plain(value)
|
|
277
|
+
if isinstance(data, (str, bytes)) or not isinstance(data, Sequence):
|
|
278
|
+
raise ValidationError("vector must be a sequence of numbers")
|
|
279
|
+
try:
|
|
280
|
+
return [float(item) for item in data]
|
|
281
|
+
except (TypeError, ValueError) as exc:
|
|
282
|
+
raise ValidationError("vector must be a sequence of numbers") from exc
|
vecspan/validate.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Turn caller input into the values backends store and query."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
from vecspan.errors import ValidationError
|
|
10
|
+
from vecspan.filters import And, Cmp, Eq, Expr, Filter, In, IsNull, Not, Or
|
|
11
|
+
from vecspan.naming import check_field_name
|
|
12
|
+
from vecspan.types import (
|
|
13
|
+
INT64_MAX,
|
|
14
|
+
CollectionSpec,
|
|
15
|
+
IdType,
|
|
16
|
+
PayloadType,
|
|
17
|
+
Point,
|
|
18
|
+
VectorField,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
PointId = int | str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def prepare_points(spec: CollectionSpec, points: object) -> list[Point]:
|
|
25
|
+
"""Validate and normalize a batch of points."""
|
|
26
|
+
items = _point_list(points)
|
|
27
|
+
prepared: list[Point] = []
|
|
28
|
+
seen: set[PointId] = set()
|
|
29
|
+
for item in items:
|
|
30
|
+
point = _normalize_point(spec, _as_point(item))
|
|
31
|
+
if point.id in seen:
|
|
32
|
+
raise ValidationError(f"duplicate point id: {point.id!r}")
|
|
33
|
+
seen.add(point.id)
|
|
34
|
+
prepared.append(point)
|
|
35
|
+
return prepared
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def prepare_ids(spec: CollectionSpec, ids: object) -> list[PointId]:
|
|
39
|
+
"""Validate a non-empty id sequence and preserve its order."""
|
|
40
|
+
items = _id_list(ids)
|
|
41
|
+
if not items:
|
|
42
|
+
raise ValidationError("ids must not be empty")
|
|
43
|
+
prepared = [normalize_id(spec, item) for item in items]
|
|
44
|
+
if len(prepared) != len(set(prepared)):
|
|
45
|
+
raise ValidationError("duplicate ids")
|
|
46
|
+
return prepared
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def prepare_payload(spec: CollectionSpec, payload: object, *, allow_empty: bool) -> dict[str, Any]:
|
|
50
|
+
"""Validate a payload mapping against declared fields."""
|
|
51
|
+
if not isinstance(payload, Mapping):
|
|
52
|
+
raise ValidationError("payload must be a mapping")
|
|
53
|
+
if not payload and not allow_empty:
|
|
54
|
+
raise ValidationError("payload must not be empty")
|
|
55
|
+
vector_names = {item.name for item in spec.vectors}
|
|
56
|
+
cleaned: dict[str, Any] = {}
|
|
57
|
+
for key, value in payload.items():
|
|
58
|
+
name = check_field_name(key)
|
|
59
|
+
if name in vector_names:
|
|
60
|
+
raise ValidationError(f"payload key {name!r} collides with a vector field")
|
|
61
|
+
cleaned[name] = _payload_value(spec, name, value)
|
|
62
|
+
return cleaned
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def prepare_vector_updates(
|
|
66
|
+
spec: CollectionSpec,
|
|
67
|
+
ids: object,
|
|
68
|
+
vectors: object,
|
|
69
|
+
) -> list[tuple[PointId, dict[str, list[float]]]]:
|
|
70
|
+
"""Validate parallel ids and partial vector replacements."""
|
|
71
|
+
prepared_ids = prepare_ids(spec, ids)
|
|
72
|
+
if isinstance(vectors, Mapping) or not isinstance(vectors, Sequence):
|
|
73
|
+
raise ValidationError("vectors must be a sequence aligned with ids")
|
|
74
|
+
if len(vectors) != len(prepared_ids):
|
|
75
|
+
raise ValidationError("vectors must have one entry per id")
|
|
76
|
+
updates: list[tuple[PointId, dict[str, list[float]]]] = []
|
|
77
|
+
for point_id, raw in zip(prepared_ids, vectors):
|
|
78
|
+
if not isinstance(raw, Mapping) or isinstance(raw, (str, bytes)):
|
|
79
|
+
raise ValidationError("each vector update must be a mapping of field name to vector")
|
|
80
|
+
if not raw:
|
|
81
|
+
raise ValidationError("a vector update must include at least one field")
|
|
82
|
+
cleaned: dict[str, list[float]] = {}
|
|
83
|
+
for name, value in raw.items():
|
|
84
|
+
if not isinstance(name, str):
|
|
85
|
+
raise ValidationError(f"unknown vector field: {name!r}")
|
|
86
|
+
field = spec.vector(name)
|
|
87
|
+
cleaned[field.name] = _vector_values(value, field)
|
|
88
|
+
updates.append((point_id, cleaned))
|
|
89
|
+
return updates
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def prepare_query_vector(
|
|
93
|
+
spec: CollectionSpec,
|
|
94
|
+
vector: object,
|
|
95
|
+
vector_name: str | None,
|
|
96
|
+
) -> tuple[VectorField, list[float]]:
|
|
97
|
+
"""Validate the query vector and resolve which field it belongs to."""
|
|
98
|
+
field = spec.vector(vector_name)
|
|
99
|
+
return field, _vector_values(vector, field)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def prepare_filter(spec: CollectionSpec, filt: Filter | None) -> Filter | None:
|
|
103
|
+
"""Check filter fields against the collection schema."""
|
|
104
|
+
if filt is None:
|
|
105
|
+
return None
|
|
106
|
+
if not isinstance(filt, Filter):
|
|
107
|
+
raise ValidationError("filter must be a Filter")
|
|
108
|
+
_check_expr(spec, filt.expr)
|
|
109
|
+
return filt
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def prepare_search_options(options: object, *, allow_nprobe: bool) -> dict[str, int]:
|
|
113
|
+
"""Normalize search tuning keys understood by the backends."""
|
|
114
|
+
if options is None:
|
|
115
|
+
return {}
|
|
116
|
+
if not isinstance(options, Mapping):
|
|
117
|
+
raise ValidationError("backend_options must be a mapping")
|
|
118
|
+
allowed = {"ef", "hnsw_ef"}
|
|
119
|
+
if allow_nprobe:
|
|
120
|
+
allowed.add("nprobe")
|
|
121
|
+
unknown = sorted(set(options) - allowed)
|
|
122
|
+
if unknown:
|
|
123
|
+
raise ValidationError(f"unknown backend option: {unknown[0]}")
|
|
124
|
+
if "ef" in options and "hnsw_ef" in options:
|
|
125
|
+
raise ValidationError("pass only one of ef and hnsw_ef")
|
|
126
|
+
cleaned: dict[str, int] = {}
|
|
127
|
+
if "ef" in options or "hnsw_ef" in options:
|
|
128
|
+
raw = options["ef"] if "ef" in options else options["hnsw_ef"]
|
|
129
|
+
cleaned["ef"] = _option_int(raw, "ef")
|
|
130
|
+
if "nprobe" in options:
|
|
131
|
+
cleaned["nprobe"] = _option_int(options["nprobe"], "nprobe")
|
|
132
|
+
return cleaned
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check_limit(limit: object) -> int:
|
|
136
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
|
|
137
|
+
raise ValidationError("limit must be a positive integer")
|
|
138
|
+
return limit
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def check_with_vectors(value: object) -> bool:
|
|
142
|
+
if not isinstance(value, bool):
|
|
143
|
+
raise ValidationError("with_vectors must be a bool")
|
|
144
|
+
return value
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def normalize_id(spec: CollectionSpec, value: object) -> PointId:
|
|
148
|
+
"""Return the canonical id for this collection."""
|
|
149
|
+
if isinstance(value, bool) or value is None:
|
|
150
|
+
raise ValidationError(f"invalid point id: {value!r}")
|
|
151
|
+
if spec.id_type == IdType.INT:
|
|
152
|
+
if isinstance(value, int):
|
|
153
|
+
if value < 0 or value > INT64_MAX:
|
|
154
|
+
raise ValidationError(f"int id out of range: {value}")
|
|
155
|
+
return value
|
|
156
|
+
raise ValidationError(f"expected an int id, got {value!r}")
|
|
157
|
+
if isinstance(value, uuid.UUID):
|
|
158
|
+
return str(value)
|
|
159
|
+
if isinstance(value, str):
|
|
160
|
+
try:
|
|
161
|
+
return str(uuid.UUID(value))
|
|
162
|
+
except ValueError:
|
|
163
|
+
raise ValidationError(f"expected a UUID id, got {value!r}") from None
|
|
164
|
+
raise ValidationError(f"expected a UUID id, got {value!r}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def coerce_stored_id(spec: CollectionSpec, value: object) -> PointId:
|
|
168
|
+
"""Normalize an id coming back from a database."""
|
|
169
|
+
plain = value.item() if callable(getattr(value, "item", None)) and not isinstance(value, (str, bytes)) else value
|
|
170
|
+
try:
|
|
171
|
+
return normalize_id(spec, plain)
|
|
172
|
+
except ValidationError:
|
|
173
|
+
if spec.id_type == IdType.INT:
|
|
174
|
+
return int(plain) # type: ignore[arg-type]
|
|
175
|
+
return str(plain)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _point_list(points: object) -> list[object]:
|
|
179
|
+
if isinstance(points, Point) or _is_point_mapping(points):
|
|
180
|
+
return [points]
|
|
181
|
+
if isinstance(points, (str, bytes)) or not isinstance(points, Sequence):
|
|
182
|
+
raise ValidationError("points must be a Point or a sequence of points")
|
|
183
|
+
return list(points)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _id_list(ids: object) -> list[object]:
|
|
187
|
+
if isinstance(ids, (str, int)) and not isinstance(ids, bool):
|
|
188
|
+
return [ids]
|
|
189
|
+
if isinstance(ids, (str, bytes)) or not isinstance(ids, Sequence):
|
|
190
|
+
raise ValidationError("ids must be a sequence")
|
|
191
|
+
return list(ids)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_point_mapping(value: object) -> bool:
|
|
195
|
+
return isinstance(value, Mapping) and "id" in value and "vectors" in value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _as_point(value: object) -> Point:
|
|
199
|
+
if isinstance(value, Point):
|
|
200
|
+
return value
|
|
201
|
+
if isinstance(value, Mapping):
|
|
202
|
+
if "id" not in value or "vectors" not in value:
|
|
203
|
+
raise ValidationError("point mapping needs id and vectors")
|
|
204
|
+
payload = value.get("payload") or {}
|
|
205
|
+
return Point(id=value["id"], vectors=value["vectors"], payload=payload)
|
|
206
|
+
raise ValidationError("point must be a Point or a mapping")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _normalize_point(spec: CollectionSpec, point: Point) -> Point:
|
|
210
|
+
point_id = normalize_id(spec, point.id)
|
|
211
|
+
if not point.vectors:
|
|
212
|
+
raise ValidationError("a point needs every vector field")
|
|
213
|
+
vectors: dict[str, list[float]] = {}
|
|
214
|
+
for field in spec.vectors:
|
|
215
|
+
if field.name not in point.vectors:
|
|
216
|
+
raise ValidationError(f"point is missing vector {field.name!r}")
|
|
217
|
+
vectors[field.name] = _vector_values(point.vectors[field.name], field)
|
|
218
|
+
extra = set(point.vectors) - set(vectors)
|
|
219
|
+
if extra:
|
|
220
|
+
name = sorted(extra)[0]
|
|
221
|
+
raise ValidationError(f"unknown vector field: {name!r}")
|
|
222
|
+
payload = prepare_payload(spec, point.payload, allow_empty=True)
|
|
223
|
+
return Point(id=point_id, vectors=vectors, payload=payload)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _vector_values(value: object, field: VectorField) -> list[float]:
|
|
227
|
+
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
|
228
|
+
raise ValidationError(f"vector {field.name!r} must be a sequence of numbers")
|
|
229
|
+
if len(value) != field.size:
|
|
230
|
+
raise ValidationError(
|
|
231
|
+
f"vector {field.name!r} has dimension {len(value)}, expected {field.size}"
|
|
232
|
+
)
|
|
233
|
+
numbers: list[float] = []
|
|
234
|
+
for item in value:
|
|
235
|
+
if isinstance(item, bool) or not isinstance(item, (int, float)):
|
|
236
|
+
raise ValidationError(f"vector {field.name!r} must contain numbers")
|
|
237
|
+
number = float(item)
|
|
238
|
+
if not math.isfinite(number):
|
|
239
|
+
raise ValidationError(f"vector {field.name!r} must be finite")
|
|
240
|
+
numbers.append(number)
|
|
241
|
+
return numbers
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _payload_value(spec: CollectionSpec, name: str, value: object) -> Any:
|
|
245
|
+
declared = spec.payload(name)
|
|
246
|
+
if declared is None:
|
|
247
|
+
return _dynamic_value(name, value)
|
|
248
|
+
if value is None:
|
|
249
|
+
return None
|
|
250
|
+
if declared.type == PayloadType.KEYWORD:
|
|
251
|
+
if not isinstance(value, str):
|
|
252
|
+
raise ValidationError(f"payload field {name!r} expects a string")
|
|
253
|
+
if len(value) > declared.max_length:
|
|
254
|
+
raise ValidationError(f"payload field {name!r} exceeds max_length {declared.max_length}")
|
|
255
|
+
return value
|
|
256
|
+
if declared.type == PayloadType.INT:
|
|
257
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
258
|
+
raise ValidationError(f"payload field {name!r} expects an int")
|
|
259
|
+
if value < -INT64_MAX - 1 or value > INT64_MAX:
|
|
260
|
+
raise ValidationError(f"payload field {name!r} is outside int64")
|
|
261
|
+
return value
|
|
262
|
+
if declared.type == PayloadType.FLOAT:
|
|
263
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
264
|
+
raise ValidationError(f"payload field {name!r} expects a number")
|
|
265
|
+
number = float(value)
|
|
266
|
+
if not math.isfinite(number):
|
|
267
|
+
raise ValidationError(f"payload field {name!r} must be finite")
|
|
268
|
+
return number
|
|
269
|
+
if isinstance(value, bool):
|
|
270
|
+
return value
|
|
271
|
+
raise ValidationError(f"payload field {name!r} expects a bool")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _dynamic_value(name: str, value: object) -> Any:
|
|
275
|
+
if value is None or isinstance(value, (str, bool)):
|
|
276
|
+
return value
|
|
277
|
+
if isinstance(value, float):
|
|
278
|
+
if not math.isfinite(value):
|
|
279
|
+
raise ValidationError(f"payload field {name!r} must be finite")
|
|
280
|
+
return value
|
|
281
|
+
if isinstance(value, int):
|
|
282
|
+
return value
|
|
283
|
+
if isinstance(value, list):
|
|
284
|
+
return [_dynamic_value(name, item) for item in value]
|
|
285
|
+
if isinstance(value, dict):
|
|
286
|
+
return {check_field_name(key): _dynamic_value(str(key), item) for key, item in value.items()}
|
|
287
|
+
raise ValidationError(f"payload field {name!r} has unsupported type {type(value).__name__}")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _check_expr(spec: CollectionSpec, expr: Expr) -> None:
|
|
291
|
+
if isinstance(expr, (Eq, In, Cmp, IsNull)):
|
|
292
|
+
_check_field_expr(spec, expr)
|
|
293
|
+
return
|
|
294
|
+
if isinstance(expr, Not):
|
|
295
|
+
_check_expr(spec, expr.inner)
|
|
296
|
+
return
|
|
297
|
+
if isinstance(expr, (And, Or)):
|
|
298
|
+
for child in expr.children:
|
|
299
|
+
_check_expr(spec, child)
|
|
300
|
+
return
|
|
301
|
+
raise ValidationError(f"unsupported filter node: {type(expr).__name__}")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _check_field_expr(spec: CollectionSpec, expr: Eq | In | Cmp | IsNull) -> None:
|
|
305
|
+
if any(field.name == expr.field for field in spec.vectors):
|
|
306
|
+
raise ValidationError(f"cannot filter on vector field {expr.field!r}")
|
|
307
|
+
declared = spec.payload(expr.field)
|
|
308
|
+
if declared is None or isinstance(expr, IsNull):
|
|
309
|
+
return
|
|
310
|
+
if declared.type == PayloadType.KEYWORD:
|
|
311
|
+
if isinstance(expr, Cmp):
|
|
312
|
+
raise ValidationError(f"cannot compare string field {expr.field!r}")
|
|
313
|
+
_expect_kind(expr, "string")
|
|
314
|
+
return
|
|
315
|
+
if declared.type == PayloadType.BOOL:
|
|
316
|
+
if isinstance(expr, Cmp):
|
|
317
|
+
raise ValidationError(f"cannot compare bool field {expr.field!r}")
|
|
318
|
+
_expect_kind(expr, "bool")
|
|
319
|
+
return
|
|
320
|
+
if declared.type == PayloadType.INT:
|
|
321
|
+
_expect_kind(expr, "int")
|
|
322
|
+
return
|
|
323
|
+
_expect_kind(expr, "number")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _expect_kind(expr: Eq | In | Cmp, kind: str) -> None:
|
|
327
|
+
values = expr.values if isinstance(expr, In) else (expr.value,)
|
|
328
|
+
for value in values:
|
|
329
|
+
if not _matches_kind(value, kind):
|
|
330
|
+
raise ValidationError(f"filter value for {expr.field!r} does not match the field type")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _matches_kind(value: object, kind: str) -> bool:
|
|
334
|
+
if kind == "string":
|
|
335
|
+
return isinstance(value, str)
|
|
336
|
+
if kind == "bool":
|
|
337
|
+
return isinstance(value, bool)
|
|
338
|
+
if kind == "int":
|
|
339
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
340
|
+
if kind == "number":
|
|
341
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
342
|
+
return False
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _option_int(value: object, label: str) -> int:
|
|
346
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
347
|
+
raise ValidationError(f"{label} must be a positive integer")
|
|
348
|
+
return value
|