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/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