polign 0.1.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.
@@ -0,0 +1,54 @@
1
+ # Binaries for programs and plugins
2
+ *.exe
3
+ *.exe~
4
+ *.dll
5
+ *.so
6
+ *.dylib
7
+
8
+ # Test binary, built with `go test -c`
9
+ *.test
10
+
11
+ # Output of the go coverage tool
12
+ *.out
13
+
14
+ # Dependency directories
15
+ vendor/
16
+
17
+ # Go workspace file
18
+ go.work
19
+ go.work.sum
20
+
21
+ # Build output (incl. `go build ./cmd/...` binaries dropped at the repo root)
22
+ /bin/
23
+ /dist/
24
+ /ann
25
+ /apikey
26
+ /ivfpq-compact
27
+ /loadtest
28
+ /maintain
29
+ /persistor
30
+ /polign
31
+ /server
32
+ example/demo/demo
33
+
34
+ # Benchmark run logs
35
+ bench/**/*.log
36
+
37
+ # Native install data dir (default POLIGN_DATA_DIR)
38
+ /.polign-data/
39
+
40
+ # Environment / secrets
41
+ .env
42
+ .env.native
43
+
44
+ # Internal design docs (kept local, not published)
45
+ /docs/
46
+
47
+ # macOS Finder metadata
48
+ .DS_Store
49
+
50
+ # Python SDK (sdk/python)
51
+ __pycache__/
52
+ *.egg-info/
53
+ sdk/python/dist/
54
+ sdk/python/.pytest_cache/
polign-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: polign
3
+ Version: 0.1.0
4
+ Summary: Python client for polign_db, a vector database with hot/cold search and hybrid BM25 fusion
5
+ Project-URL: Homepage, https://polign.com
6
+ Project-URL: Documentation, https://polign.com/python.html
7
+ Project-URL: Issues, https://github.com/Polign/polign/issues
8
+ Author: Polign
9
+ License: Apache-2.0
10
+ Keywords: embeddings,polign,similarity search,vector database,vector search
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Database
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.9
18
+ Provides-Extra: grpc
19
+ Requires-Dist: grpcio>=1.80; extra == 'grpc'
20
+ Requires-Dist: protobuf<7,>=6.31; extra == 'grpc'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # polign — Python client for polign_db
24
+
25
+ A thin Python client for [polign](https://polign.com) with
26
+ two interchangeable transports:
27
+
28
+ - **HTTP** (`polign.Client`) — zero dependencies, pure stdlib. Talks JSON to
29
+ the server's HTTP listener (default `:23000`).
30
+ - **gRPC** (`polign.GrpcClient`) — install with the `[grpc]` extra.
31
+ Wire-compatible with the Go client, against the gRPC listener (default
32
+ `:23001`).
33
+
34
+ Both expose the same seven operations with identical semantics: `put`,
35
+ `put_many`, `get`, `get_many`, `list`, `delete`, `search`.
36
+
37
+ ## Install
38
+
39
+ Not yet published to PyPI — install from a checkout of the repo:
40
+
41
+ ```bash
42
+ pip install ./sdk/python # HTTP client, no dependencies
43
+ pip install './sdk/python[grpc]' # + gRPC transport (grpcio, protobuf)
44
+ ```
45
+
46
+ (From inside this directory: `pip install .`; add `-e` for an editable
47
+ development install — needs pip ≥ 21.3.)
48
+
49
+ ## Quick start
50
+
51
+ ```python
52
+ from polign import Client
53
+
54
+ client = Client("http://localhost:23000")
55
+
56
+ # Upsert. Collections are auto-created on first put, inferring their
57
+ # dimension from the vector. Values accept lists or numpy arrays.
58
+ client.put("docs", "doc-1", embedding, metadata={"title": "Cats", "url": "/cats"})
59
+
60
+ # Nearest-neighbour search (distance: smaller = closer)
61
+ for hit in client.search("docs", values=query_embedding, k=10):
62
+ print(hit.id, hit.distance, hit.metadata)
63
+ ```
64
+
65
+ Swap in gRPC by changing two lines — the rest of the code is identical:
66
+
67
+ ```python
68
+ from polign import GrpcClient
69
+
70
+ client = GrpcClient("localhost:23001")
71
+ ```
72
+
73
+ ## Operations
74
+
75
+ ```python
76
+ client.put("docs", "doc-1", values, metadata={"k": "v"}) # upsert, returns id
77
+ client.put_many("docs", [Vector(id="a", values=va), Vector(id="b", values=vb)])
78
+ # batch upsert, one request
79
+ v = client.get("docs", "doc-1") # Vector(id, values, metadata)
80
+ vs = client.get_many("docs", ["a", "b"]) # batch read, byte-exact values:
81
+ # never a compressed reconstruction
82
+ # (get may return one on a cold-
83
+ # flushed collection); unknown ids
84
+ # omitted, request order kept
85
+ page = client.list("docs", limit=100, offset=0) # page.vectors, page.total
86
+ client.delete("docs", "doc-1") # True; False if absent (no error)
87
+ hits = client.search("docs", values=q, k=10) # [Hit(id, distance, score, metadata)]
88
+ ```
89
+
90
+ ### Search options
91
+
92
+ ```python
93
+ from polign import Fusion
94
+
95
+ client.search(
96
+ "docs",
97
+ values=q, # vector leg (either values or text required)
98
+ k=10,
99
+ ef=64, # HNSW beam width override (0 = server default)
100
+ filter={"lang": "en"}, # metadata predicate (see below)
101
+ text="quick brown fox", # BM25 leg (needs a segment index server-side)
102
+ fusion=Fusion(method="linear", alpha=0.6), # hybrid fusion; default RRF
103
+ cold=True, nprobe=8, # serve from object-store segments
104
+ )
105
+ ```
106
+
107
+ `text` alone runs a pure BM25 search; `values` + `text` runs hybrid search
108
+ fused server-side. `hit.score` is the BM25/fused relevance (larger = better)
109
+ and is `0.0` on a pure vector search.
110
+
111
+ `filter` takes the same dict language on both transports (see
112
+ `docs/FILTERING.md` in the main repo): bare values are equality
113
+ (ANDed across keys); per-key operator objects (`$eq`, `$ne`, `$in`, `$gt`,
114
+ `$gte`, `$lt`, `$lte`, `$exists`) and the composers `$and`/`$or`/`$not`
115
+ express richer predicates:
116
+
117
+ ```python
118
+ filter={
119
+ "tenant": "acme",
120
+ "score": {"$gte": 0.5},
121
+ "$or": [{"lang": "en"}, {"lang": {"$exists": False}}],
122
+ }
123
+ ```
124
+
125
+ ## Auth and tenants
126
+
127
+ ```python
128
+ client = Client(
129
+ "https://db.example.com:23000",
130
+ api_key="plgn_<key_id>_<secret>", # sent as Authorization: Bearer
131
+ tenant="acme/search/prod", # org/project/namespace
132
+ )
133
+ ```
134
+
135
+ Servers started without `-auth-stores` need no credentials. With TLS enabled
136
+ server-side, use an `https://` URL (HTTP) or pass
137
+ `credentials=grpc.ssl_channel_credentials()` (gRPC).
138
+
139
+ ## Errors
140
+
141
+ All errors subclass `polign.PolignError`:
142
+
143
+ | Exception | HTTP | gRPC |
144
+ |-------------------------|------|----------------------|
145
+ | `InvalidArgumentError` | 400 | `INVALID_ARGUMENT` |
146
+ | `AuthenticationError` | 401 | `UNAUTHENTICATED` |
147
+ | `PermissionDeniedError` | 403 | `PERMISSION_DENIED` |
148
+ | `NotFoundError` | 404 | `NOT_FOUND` |
149
+ | `NotOwnerError` | 421 | `FAILED_PRECONDITION`|
150
+ | `RateLimitError` | 429 | `RESOURCE_EXHAUSTED` |
151
+ | `ServerError` | 5xx | `INTERNAL` |
152
+ | `ConnectionError` | — | `UNAVAILABLE` |
153
+
154
+ `NotOwnerError.owner` names the owning node in fleet mode — reconnect there
155
+ and retry.
156
+
157
+ ## Notes & caveats
158
+
159
+ Mirrors the Go client's caveats (see `docs/CLIENT.md` in the main repo):
160
+
161
+ - Embed documents and queries with the **same model** — distances are only
162
+ meaningful within one embedding space.
163
+ - Auto-created collections use the server's default metric (L2) and hybrid
164
+ IVF index; metric and index tuning are not yet exposed over the wire.
165
+ - **Bulk loads should use `put_many`** — one request per batch instead of one
166
+ per vector. The server validates the whole batch up front (id, non-empty
167
+ values, uniform dimension, at most 5000 vectors per batch: an invalid batch
168
+ applies nothing); on a rarer mid-batch failure earlier vectors remain
169
+ applied, and since puts are idempotent upserts you simply retry the batch.
170
+ Chunk larger loads into batches of 5000.
171
+ - Metadata is `str -> str` only; filter scalars (numbers, booleans) compare
172
+ against the stored string by their literal form (`"0.5"`, `"true"`).
173
+
174
+ ## Development
175
+
176
+ ```bash
177
+ cd sdk/python
178
+ pip install -e .[grpc] pytest
179
+ pytest tests/test_unit.py # stub-server unit tests
180
+ pytest tests/test_integration.py # builds & boots the real server (needs Go)
181
+ ```
182
+
183
+ Regenerate the vendored gRPC stubs after changing `proto/vectordb.proto`
184
+ (from the repo root):
185
+
186
+ ```bash
187
+ python -m grpc_tools.protoc -I proto \
188
+ --python_out=sdk/python/polign/_pb \
189
+ --grpc_python_out=sdk/python/polign/_pb \
190
+ --pyi_out=sdk/python/polign/_pb \
191
+ proto/vectordb.proto
192
+ sed -i '' 's/^import vectordb_pb2 as/from . import vectordb_pb2 as/' \
193
+ sdk/python/polign/_pb/vectordb_pb2_grpc.py
194
+ ```
polign-0.1.0/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # polign — Python client for polign_db
2
+
3
+ A thin Python client for [polign](https://polign.com) with
4
+ two interchangeable transports:
5
+
6
+ - **HTTP** (`polign.Client`) — zero dependencies, pure stdlib. Talks JSON to
7
+ the server's HTTP listener (default `:23000`).
8
+ - **gRPC** (`polign.GrpcClient`) — install with the `[grpc]` extra.
9
+ Wire-compatible with the Go client, against the gRPC listener (default
10
+ `:23001`).
11
+
12
+ Both expose the same seven operations with identical semantics: `put`,
13
+ `put_many`, `get`, `get_many`, `list`, `delete`, `search`.
14
+
15
+ ## Install
16
+
17
+ Not yet published to PyPI — install from a checkout of the repo:
18
+
19
+ ```bash
20
+ pip install ./sdk/python # HTTP client, no dependencies
21
+ pip install './sdk/python[grpc]' # + gRPC transport (grpcio, protobuf)
22
+ ```
23
+
24
+ (From inside this directory: `pip install .`; add `-e` for an editable
25
+ development install — needs pip ≥ 21.3.)
26
+
27
+ ## Quick start
28
+
29
+ ```python
30
+ from polign import Client
31
+
32
+ client = Client("http://localhost:23000")
33
+
34
+ # Upsert. Collections are auto-created on first put, inferring their
35
+ # dimension from the vector. Values accept lists or numpy arrays.
36
+ client.put("docs", "doc-1", embedding, metadata={"title": "Cats", "url": "/cats"})
37
+
38
+ # Nearest-neighbour search (distance: smaller = closer)
39
+ for hit in client.search("docs", values=query_embedding, k=10):
40
+ print(hit.id, hit.distance, hit.metadata)
41
+ ```
42
+
43
+ Swap in gRPC by changing two lines — the rest of the code is identical:
44
+
45
+ ```python
46
+ from polign import GrpcClient
47
+
48
+ client = GrpcClient("localhost:23001")
49
+ ```
50
+
51
+ ## Operations
52
+
53
+ ```python
54
+ client.put("docs", "doc-1", values, metadata={"k": "v"}) # upsert, returns id
55
+ client.put_many("docs", [Vector(id="a", values=va), Vector(id="b", values=vb)])
56
+ # batch upsert, one request
57
+ v = client.get("docs", "doc-1") # Vector(id, values, metadata)
58
+ vs = client.get_many("docs", ["a", "b"]) # batch read, byte-exact values:
59
+ # never a compressed reconstruction
60
+ # (get may return one on a cold-
61
+ # flushed collection); unknown ids
62
+ # omitted, request order kept
63
+ page = client.list("docs", limit=100, offset=0) # page.vectors, page.total
64
+ client.delete("docs", "doc-1") # True; False if absent (no error)
65
+ hits = client.search("docs", values=q, k=10) # [Hit(id, distance, score, metadata)]
66
+ ```
67
+
68
+ ### Search options
69
+
70
+ ```python
71
+ from polign import Fusion
72
+
73
+ client.search(
74
+ "docs",
75
+ values=q, # vector leg (either values or text required)
76
+ k=10,
77
+ ef=64, # HNSW beam width override (0 = server default)
78
+ filter={"lang": "en"}, # metadata predicate (see below)
79
+ text="quick brown fox", # BM25 leg (needs a segment index server-side)
80
+ fusion=Fusion(method="linear", alpha=0.6), # hybrid fusion; default RRF
81
+ cold=True, nprobe=8, # serve from object-store segments
82
+ )
83
+ ```
84
+
85
+ `text` alone runs a pure BM25 search; `values` + `text` runs hybrid search
86
+ fused server-side. `hit.score` is the BM25/fused relevance (larger = better)
87
+ and is `0.0` on a pure vector search.
88
+
89
+ `filter` takes the same dict language on both transports (see
90
+ `docs/FILTERING.md` in the main repo): bare values are equality
91
+ (ANDed across keys); per-key operator objects (`$eq`, `$ne`, `$in`, `$gt`,
92
+ `$gte`, `$lt`, `$lte`, `$exists`) and the composers `$and`/`$or`/`$not`
93
+ express richer predicates:
94
+
95
+ ```python
96
+ filter={
97
+ "tenant": "acme",
98
+ "score": {"$gte": 0.5},
99
+ "$or": [{"lang": "en"}, {"lang": {"$exists": False}}],
100
+ }
101
+ ```
102
+
103
+ ## Auth and tenants
104
+
105
+ ```python
106
+ client = Client(
107
+ "https://db.example.com:23000",
108
+ api_key="plgn_<key_id>_<secret>", # sent as Authorization: Bearer
109
+ tenant="acme/search/prod", # org/project/namespace
110
+ )
111
+ ```
112
+
113
+ Servers started without `-auth-stores` need no credentials. With TLS enabled
114
+ server-side, use an `https://` URL (HTTP) or pass
115
+ `credentials=grpc.ssl_channel_credentials()` (gRPC).
116
+
117
+ ## Errors
118
+
119
+ All errors subclass `polign.PolignError`:
120
+
121
+ | Exception | HTTP | gRPC |
122
+ |-------------------------|------|----------------------|
123
+ | `InvalidArgumentError` | 400 | `INVALID_ARGUMENT` |
124
+ | `AuthenticationError` | 401 | `UNAUTHENTICATED` |
125
+ | `PermissionDeniedError` | 403 | `PERMISSION_DENIED` |
126
+ | `NotFoundError` | 404 | `NOT_FOUND` |
127
+ | `NotOwnerError` | 421 | `FAILED_PRECONDITION`|
128
+ | `RateLimitError` | 429 | `RESOURCE_EXHAUSTED` |
129
+ | `ServerError` | 5xx | `INTERNAL` |
130
+ | `ConnectionError` | — | `UNAVAILABLE` |
131
+
132
+ `NotOwnerError.owner` names the owning node in fleet mode — reconnect there
133
+ and retry.
134
+
135
+ ## Notes & caveats
136
+
137
+ Mirrors the Go client's caveats (see `docs/CLIENT.md` in the main repo):
138
+
139
+ - Embed documents and queries with the **same model** — distances are only
140
+ meaningful within one embedding space.
141
+ - Auto-created collections use the server's default metric (L2) and hybrid
142
+ IVF index; metric and index tuning are not yet exposed over the wire.
143
+ - **Bulk loads should use `put_many`** — one request per batch instead of one
144
+ per vector. The server validates the whole batch up front (id, non-empty
145
+ values, uniform dimension, at most 5000 vectors per batch: an invalid batch
146
+ applies nothing); on a rarer mid-batch failure earlier vectors remain
147
+ applied, and since puts are idempotent upserts you simply retry the batch.
148
+ Chunk larger loads into batches of 5000.
149
+ - Metadata is `str -> str` only; filter scalars (numbers, booleans) compare
150
+ against the stored string by their literal form (`"0.5"`, `"true"`).
151
+
152
+ ## Development
153
+
154
+ ```bash
155
+ cd sdk/python
156
+ pip install -e .[grpc] pytest
157
+ pytest tests/test_unit.py # stub-server unit tests
158
+ pytest tests/test_integration.py # builds & boots the real server (needs Go)
159
+ ```
160
+
161
+ Regenerate the vendored gRPC stubs after changing `proto/vectordb.proto`
162
+ (from the repo root):
163
+
164
+ ```bash
165
+ python -m grpc_tools.protoc -I proto \
166
+ --python_out=sdk/python/polign/_pb \
167
+ --grpc_python_out=sdk/python/polign/_pb \
168
+ --pyi_out=sdk/python/polign/_pb \
169
+ proto/vectordb.proto
170
+ sed -i '' 's/^import vectordb_pb2 as/from . import vectordb_pb2 as/' \
171
+ sdk/python/polign/_pb/vectordb_pb2_grpc.py
172
+ ```
@@ -0,0 +1,66 @@
1
+ """polign — Python client for polign_db.
2
+
3
+ HTTP client (zero dependencies):
4
+
5
+ from polign import Client
6
+ c = Client("http://localhost:23000")
7
+
8
+ gRPC client (pip install polign[grpc]):
9
+
10
+ from polign import GrpcClient
11
+ c = GrpcClient("localhost:23001")
12
+
13
+ Both expose the same operations: put, put_many, get, list, delete, search.
14
+ """
15
+
16
+ from .client import Client
17
+ from .errors import (
18
+ AuthenticationError,
19
+ ConflictError,
20
+ ConnectionError,
21
+ InvalidArgumentError,
22
+ NotEnabledError,
23
+ NotFoundError,
24
+ NotOwnerError,
25
+ PermissionDeniedError,
26
+ PolignError,
27
+ RateLimitError,
28
+ ServerError,
29
+ UnavailableError,
30
+ )
31
+ from .types import CollectionBackend, CollectionInfo, Fusion, Hit, Vector, VectorPage
32
+
33
+ __version__ = "0.1.0rc8"
34
+
35
+ __all__ = [
36
+ "Client",
37
+ "GrpcClient",
38
+ "Vector",
39
+ "Hit",
40
+ "Fusion",
41
+ "VectorPage",
42
+ "PolignError",
43
+ "ConnectionError",
44
+ "InvalidArgumentError",
45
+ "NotFoundError",
46
+ "AuthenticationError",
47
+ "PermissionDeniedError",
48
+ "RateLimitError",
49
+ "NotOwnerError",
50
+ "ServerError",
51
+ "ConflictError",
52
+ "NotEnabledError",
53
+ "UnavailableError",
54
+ "CollectionBackend",
55
+ "CollectionInfo",
56
+ "__version__",
57
+ ]
58
+
59
+
60
+ def __getattr__(name):
61
+ # Lazy import so the base package works without grpcio installed.
62
+ if name == "GrpcClient":
63
+ from .grpc_client import GrpcClient
64
+
65
+ return GrpcClient
66
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,161 @@
1
+ """Converts the dict filter language into the wire FilterExpr tree.
2
+
3
+ The gRPC client accepts the same metadata-filter dicts as the HTTP client
4
+ (the syntax of docs/FILTERING.md) and converts them client-side into the
5
+ ``polign.v1.FilterExpr`` proto. The semantics mirror the server's JSON parser
6
+ (internal/filter/json.go): bare values are equality, per-key operator objects
7
+ ($eq, $ne, $in, $gt, $gte, $lt, $lte, $exists) AND together with the four
8
+ range operators merged into one range, and $and/$or/$not compose sub-filters.
9
+
10
+ Only imported by the gRPC transport — needs the ``grpc`` extra for protobuf.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from . import errors
18
+ from ._pb import vectordb_pb2 as pb
19
+
20
+ _RANGE_OPS = {"$gt": "gt", "$gte": "gte", "$lt": "lt", "$lte": "lte"}
21
+
22
+
23
+ def filter_expr_from_dict(obj: Optional[Dict[str, Any]]) -> Optional["pb.FilterExpr"]:
24
+ """Convert a filter dict to a FilterExpr, or None for no filter.
25
+
26
+ Raises :class:`polign.InvalidArgumentError` on a malformed filter, with
27
+ the same messages the server would produce.
28
+ """
29
+ if not obj:
30
+ return None
31
+ if not isinstance(obj, dict):
32
+ raise errors.InvalidArgumentError("filter: not a JSON object")
33
+ return _parse_object(obj)
34
+
35
+
36
+ def _parse_object(obj: Dict[str, Any]) -> Optional["pb.FilterExpr"]:
37
+ exprs: List[pb.FilterExpr] = []
38
+ for key in sorted(obj):
39
+ val = obj[key]
40
+ if key in ("$and", "$or"):
41
+ children = _parse_object_list(key, val)
42
+ junction = pb.FilterJunction(exprs=children)
43
+ if key == "$and":
44
+ exprs.append(pb.FilterExpr(**{"and": junction}))
45
+ else:
46
+ exprs.append(pb.FilterExpr(**{"or": junction}))
47
+ elif key == "$not":
48
+ if not isinstance(val, dict):
49
+ raise errors.InvalidArgumentError("filter: $not takes a filter object")
50
+ child = _parse_object(val)
51
+ if child is None:
52
+ raise errors.InvalidArgumentError(
53
+ "filter: $not takes a non-empty filter object"
54
+ )
55
+ exprs.append(pb.FilterExpr(**{"not": child}))
56
+ elif key.startswith("$"):
57
+ raise errors.InvalidArgumentError(f"filter: unknown operator {key!r}")
58
+ else:
59
+ exprs.append(_parse_field(key, val))
60
+ return _and_collapse(exprs)
61
+
62
+
63
+ def _parse_object_list(op: str, val: Any) -> List["pb.FilterExpr"]:
64
+ if not isinstance(val, list) or not val:
65
+ raise errors.InvalidArgumentError(
66
+ f"filter: {op} takes a non-empty array of filter objects"
67
+ )
68
+ children = []
69
+ for item in val:
70
+ if not isinstance(item, dict):
71
+ raise errors.InvalidArgumentError(
72
+ f"filter: {op} takes a non-empty array of filter objects"
73
+ )
74
+ child = _parse_object(item)
75
+ if child is None:
76
+ raise errors.InvalidArgumentError(f"filter: {op}: empty filter object")
77
+ children.append(child)
78
+ return children
79
+
80
+
81
+ def _parse_field(key: str, val: Any) -> "pb.FilterExpr":
82
+ if isinstance(val, dict):
83
+ return _parse_field_ops(key, val)
84
+ return _cond(pb.FilterCond(key=key, eq=_scalar(key, val)))
85
+
86
+
87
+ def _parse_field_ops(key: str, ops: Dict[str, Any]) -> "pb.FilterExpr":
88
+ if not ops:
89
+ raise errors.InvalidArgumentError(f"filter: key {key!r}: empty operator object")
90
+ exprs: List[pb.FilterExpr] = []
91
+ bounds: Dict[str, str] = {}
92
+ numeric_set = lex_set = False
93
+ for op in sorted(ops):
94
+ val = ops[op]
95
+ if op == "$eq":
96
+ exprs.append(_cond(pb.FilterCond(key=key, eq=_scalar(key, val))))
97
+ elif op == "$ne":
98
+ eq = _cond(pb.FilterCond(key=key, eq=_scalar(key, val)))
99
+ exprs.append(pb.FilterExpr(**{"not": eq}))
100
+ elif op == "$in":
101
+ if not isinstance(val, list):
102
+ raise errors.InvalidArgumentError(f"filter: key {key!r}: $in takes an array")
103
+ values = [_scalar(key, item) for item in val]
104
+ exprs.append(_cond(pb.FilterCond(key=key, **{"in": pb.ValueList(values=values)})))
105
+ elif op in _RANGE_OPS:
106
+ if isinstance(val, str):
107
+ lex_set = True
108
+ elif isinstance(val, (int, float)) and not isinstance(val, bool):
109
+ numeric_set = True
110
+ else:
111
+ raise errors.InvalidArgumentError(
112
+ f"filter: key {key!r}: {op} takes a string or number"
113
+ )
114
+ bounds[_RANGE_OPS[op]] = val if isinstance(val, str) else str(val)
115
+ elif op == "$exists":
116
+ if not isinstance(val, bool):
117
+ raise errors.InvalidArgumentError(
118
+ f"filter: key {key!r}: $exists takes a boolean"
119
+ )
120
+ exprs.append(_cond(pb.FilterCond(key=key, exists=val)))
121
+ else:
122
+ raise errors.InvalidArgumentError(
123
+ f"filter: key {key!r}: unknown operator {op!r}"
124
+ )
125
+ if bounds:
126
+ if numeric_set and lex_set:
127
+ raise errors.InvalidArgumentError(
128
+ f"filter: key {key!r}: range bounds mix numbers and strings"
129
+ )
130
+ rng = pb.FilterRange(numeric=numeric_set, **bounds)
131
+ exprs.append(_cond(pb.FilterCond(key=key, range=rng)))
132
+ collapsed = _and_collapse(exprs)
133
+ assert collapsed is not None # ops was non-empty
134
+ return collapsed
135
+
136
+
137
+ def _and_collapse(exprs: List["pb.FilterExpr"]) -> Optional["pb.FilterExpr"]:
138
+ if not exprs:
139
+ return None
140
+ if len(exprs) == 1:
141
+ return exprs[0]
142
+ return pb.FilterExpr(**{"and": pb.FilterJunction(exprs=exprs)})
143
+
144
+
145
+ def _cond(c: "pb.FilterCond") -> "pb.FilterExpr":
146
+ return pb.FilterExpr(cond=c)
147
+
148
+
149
+ def _scalar(key: str, val: Any) -> str:
150
+ """A scalar's metadata string form: strings as-is, numbers by their
151
+ literal ("0.5"), booleans as "true"/"false" — matching what the HTTP
152
+ client's JSON encoding would send."""
153
+ if isinstance(val, bool): # before int: bool subclasses int
154
+ return "true" if val else "false"
155
+ if isinstance(val, str):
156
+ return val
157
+ if isinstance(val, (int, float)):
158
+ return str(val)
159
+ raise errors.InvalidArgumentError(
160
+ f"filter: key {key!r}: expected a string, number or boolean"
161
+ )
File without changes