nidus 0.40.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,10 @@
1
+ # virtualenv
2
+ .venv/
3
+ # build output
4
+ dist/
5
+ *.egg-info/
6
+ # caches
7
+ __pycache__/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .pytest_cache/
nidus-0.40.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 duckedup
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
nidus-0.40.0/PKG-INFO ADDED
@@ -0,0 +1,361 @@
1
+ Metadata-Version: 2.4
2
+ Name: nidus
3
+ Version: 0.40.0
4
+ Summary: Python client for nidus — a small, fast vector store. Connects to a local or remote `nidus serve` over HTTP.
5
+ Project-URL: Homepage, https://nidus.duckedup.org
6
+ Project-URL: Documentation, https://nidus.duckedup.org/sdks/python/
7
+ Project-URL: Repository, https://github.com/duckedup/nidus
8
+ Project-URL: Source, https://github.com/duckedup/nidus/tree/main/sdks/python
9
+ Project-URL: Issues, https://github.com/duckedup/nidus/issues
10
+ Author: duckedup
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: client,embeddings,nidus,sdk,semantic-search,similarity-search,vector,vector-database,vector-store
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Database
26
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.9
29
+ Provides-Extra: async
30
+ Requires-Dist: httpx>=0.27; extra == 'async'
31
+ Provides-Extra: dev
32
+ Requires-Dist: build>=1.2; extra == 'dev'
33
+ Requires-Dist: httpx>=0.27; extra == 'dev'
34
+ Requires-Dist: mypy>=1.13; extra == 'dev'
35
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
36
+ Requires-Dist: pytest>=8.0; extra == 'dev'
37
+ Requires-Dist: ruff>=0.8; extra == 'dev'
38
+ Requires-Dist: twine>=5.1; extra == 'dev'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # nidus (Python)
42
+
43
+ The Python client for [nidus](https://nidus.duckedup.org) — a small, fast vector store.
44
+ This package drives a running `nidus serve` instance over HTTP, whether it is on your
45
+ laptop or a remote host.
46
+
47
+ ```bash
48
+ pip install nidus # the sync client — pulls ZERO dependencies
49
+ pip install 'nidus[async]' # adds AsyncNidusClient (httpx)
50
+ ```
51
+
52
+ `NidusClient` is built on `urllib.request`, so installing this package brings nothing
53
+ else with it — the same zero-dependency posture the JS SDK gets from the platform
54
+ `fetch`. Only the async client needs a third-party HTTP stack, and it is quarantined
55
+ behind the `async` extra. Python 3.9+.
56
+
57
+ This package is versioned in lockstep with nidus itself: the crate's version is the
58
+ single source of truth, so a given `nidus` release on PyPI is the client for the
59
+ identically-numbered nidus release. Match the two and the wire contract lines up.
60
+
61
+ ## Connecting
62
+
63
+ "Local vs remote" is just the base URL — point the client at a local `nidus serve` or
64
+ any reachable host.
65
+
66
+ ```python
67
+ import os
68
+
69
+ from nidus import NidusClient
70
+
71
+ # Local
72
+ db = NidusClient("http://127.0.0.1:7700")
73
+
74
+ # Remote, with the bearer token the server was started with (`nidus serve --token`)
75
+ db = NidusClient(
76
+ "https://nidus.internal.example.com",
77
+ token=os.environ["NIDUS_TOKEN"],
78
+ timeout=5.0, # per-request timeout in SECONDS; None (the default) means no timeout
79
+ )
80
+ ```
81
+
82
+ Both clients work as context managers. Nothing is opened until the first request, and the
83
+ default `urllib` transport is connectionless, so `close()` matters only once a pooled
84
+ transport (below) or the async client is in play — using `with` means you never have to
85
+ remember which case you are in:
86
+
87
+ ```python
88
+ with NidusClient("http://127.0.0.1:7700") as db:
89
+ print(db.health()) # True when the server answers; never raises
90
+ ```
91
+
92
+ ### The async client
93
+
94
+ `AsyncNidusClient` mirrors the sync client method for method, with `async def` and
95
+ `aclose()`. It requires `pip install 'nidus[async]'`; without `httpx` the import fails
96
+ with an `ImportError` that names that fix. Either spelling works:
97
+
98
+ ```python
99
+ from nidus.aio import AsyncNidusClient # explicit
100
+ import nidus; nidus.AsyncNidusClient # lazy — resolved on first attribute access
101
+ ```
102
+
103
+ `import nidus` itself never touches `httpx`, which is what keeps the dependency
104
+ genuinely optional.
105
+
106
+ ```python
107
+ import asyncio
108
+ from nidus import f
109
+ from nidus.aio import AsyncNidusClient
110
+
111
+ async def main():
112
+ async with AsyncNidusClient("http://127.0.0.1:7700") as db:
113
+ await db.create_collection("docs")
114
+ await db.upsert("docs", [{"id": "a", "vector": [0.1, 0.2, 0.3]}])
115
+ hits = await db.search(query=[0.1, 0.2, 0.3], top_k=5, filter=[f.eq("lang", "rust")])
116
+
117
+ asyncio.run(main())
118
+ ```
119
+
120
+ ## Upserting and searching
121
+
122
+ `attrs` accept plain Python values — `str`, `int`, `bool`, lists of `str`, and `None` —
123
+ and are normalized to nidus's typed values for you. Results come back with `attrs`
124
+ decoded to plain Python values.
125
+
126
+ ```python
127
+ db.create_collection("docs")
128
+
129
+ db.upsert("docs", [
130
+ {"id": "a", "vector": [0.1, 0.2, 0.3], "attrs": {"lang": "rust", "year": 2024}},
131
+ {"id": "b", "vector": [0.4, 0.5, 0.6], "attrs": {"lang": "go", "year": 2023}},
132
+ # a text-only doc — omit the vector
133
+ {"id": "c", "attrs": {"body": "vector stores are neat"}},
134
+ ])
135
+
136
+ for hit in db.search(query=[0.1, 0.2, 0.3], top_k=5):
137
+ print(hit.collection, hit.id, hit.score, hit.attrs.get("lang"))
138
+ ```
139
+
140
+ `upsert` and `delete` return a count; the search family returns a list of `Hit`
141
+ dataclasses (`collection`, `id`, `score`, `attrs`). `attrs` is a plain `dict`, so reach
142
+ for `.get` unless every record in scope is known to carry the key — a search spans
143
+ whatever the scope holds, and attrs are per-record, not a schema.
144
+
145
+ There is no float attribute type — floats belong in the vector, so passing one raises
146
+ `TypeError` rather than silently truncating. For an explicit type, use the `v.*`
147
+ helpers (`v.str`, `v.int`, `v.bool`, `v.list`, `v.nil`):
148
+
149
+ ```python
150
+ from nidus import v
151
+
152
+ db.upsert("docs", [{"id": "d", "attrs": {"tags": v.list(["a", "b"]), "rank": v.int(7)}}])
153
+ ```
154
+
155
+ `v.nil()` is the explicit `Null` value — "set, and empty" — which is a different fact
156
+ from an absent key ("not set / not indexed"). The SDK keeps them apart.
157
+
158
+ ## Filtering
159
+
160
+ Build an AND-filter with the `f.*` helpers. Each predicate is a positive assertion about
161
+ a **present** attribute, so an absent key matches nothing — including the negative
162
+ predicates (`ne`, `not_in`) and the ranges.
163
+
164
+ ```python
165
+ from nidus import f
166
+
167
+ hits = db.search(
168
+ query=[0.1, 0.2, 0.3],
169
+ top_k=10,
170
+ filter=f.and_(
171
+ f.eq("lang", "rust"),
172
+ f.ge("year", 2020),
173
+ f.in_("status", ["published", "draft"]),
174
+ f.glob("path", "src/*"),
175
+ ),
176
+ )
177
+ ```
178
+
179
+ Predicates: `eq`, `ne`, `glob`, `in_`, `not_in`, `lt`, `le`, `gt`, `ge`, plus `and_`.
180
+ The three trailing underscores are not style — `in` and `and` are reserved words in
181
+ Python, so `f.in_`, `f.not_in`, and `f.and_` are the JS SDK's `f.in`, `f.notIn`, and
182
+ `f.and`. Nothing else deviates.
183
+
184
+ A `Filter` is just a `list` of predicates, AND-combined, so `f.and_(...)` is sugar for
185
+ building that list — `filter=[f.eq("lang", "rust")]` is equally valid.
186
+
187
+ Comparisons are same-type only (int↔int numeric, str↔str lexical, bool↔bool). A range
188
+ predicate against a mismatched type matches nothing, which is the usual reason a filter
189
+ mysteriously returns no rows.
190
+
191
+ ## Full-text and hybrid search
192
+
193
+ ```python
194
+ db.set_fts_schema("docs", ["body"])
195
+
196
+ # BM25 text search over one indexed field
197
+ text_hits = db.text_search(field="body", query="vector store", top_k=10)
198
+
199
+ # Fuse a vector query and a BM25 query via reciprocal rank fusion
200
+ hybrid_hits = db.hybrid_search(
201
+ vector=[0.1, 0.2, 0.3],
202
+ field="body",
203
+ text="vector store",
204
+ top_k=10,
205
+ )
206
+ ```
207
+
208
+ `hybrid_search` takes no `min_score`: its score is a fused RRF rank, not a similarity,
209
+ so there is no meaningful floor to set. `rrf_k` and `candidates` tune the fusion.
210
+
211
+ ## Remembering and recalling (text-native)
212
+
213
+ When the server is started with an embedder (`nidus serve --embed-provider …`) you can
214
+ send **text** and let the server embed it — no need to compute vectors client-side.
215
+ `remember` embeds and upserts; `recall` embeds the query and vector-searches.
216
+
217
+ ```python
218
+ # Embed "the quick brown fox" and store it under id "a"
219
+ db.remember("notes", "a", "the quick brown fox", attrs={"tag": "x"})
220
+
221
+ # Summarize first, then embed the summary (the server also needs --summarize-provider).
222
+ # The stored record additionally carries `nidus.summary` and `nidus.source` attrs.
223
+ db.remember("notes", "b", long_article, mode="summarize")
224
+
225
+ # Embed the query text and search, best first
226
+ hits = db.recall("notes", "quick fox", top_k=5, min_score=0.2, filter=[f.eq("tag", "x")])
227
+ ```
228
+
229
+ Both raise `NidusError` with status `400` against a server that has **no embedder
230
+ configured** (the message names `--embed-provider`), and `mode="summarize"` without a
231
+ summarizer is likewise a `400`. The client only ever sends text; the embedding always
232
+ happens server-side.
233
+
234
+ ## Everything else
235
+
236
+ Every endpoint of the HTTP API has a method:
237
+
238
+ ```python
239
+ db.collections() # list[str]
240
+ db.stats() # dimension, distance, ANN config, collections, footprint
241
+ db.list(scope=["docs"], filter=[f.eq("lang", "rust")], offset=0, limit=50)
242
+ db.records("docs") # every record, attrs decoded; vector is None for text-only
243
+ db.get_meta("docs"); db.set_meta("docs", {"owner": "search-team"})
244
+ db.delete("docs", ["a"]) # by id
245
+ db.delete_where("docs", f.and_(f.lt("year", 2000)))
246
+ db.flush(); db.compact()
247
+ db.drop_collection("docs")
248
+ db.health() # bool
249
+ ```
250
+
251
+ Optional arguments all default to `None`, which means "omit the key" so the **server's**
252
+ default applies (`top_k = 10`, `limit = 100`, `rrf_k = 60.0`, `candidates = 100`). Those
253
+ numbers are deliberately not restated in Python, and it is why the defaults are `None`
254
+ rather than a number: `top_k=0` is a legitimate request for zero results, so `0` cannot
255
+ double as "unset".
256
+
257
+ `stats().ann` is `None` when the store does exact brute-force search, rather than an
258
+ `AnnInfo` full of defaults. Likewise `Record.vector` is `None` — never `[]` — for a
259
+ text-only document.
260
+
261
+ ## Three things the client refuses to send
262
+
263
+ Python's type system cannot express two mistakes that produce a **well-formed** request the
264
+ server accepts and answers wrongly, so the SDK refuses them at the call site instead:
265
+
266
+ ```python
267
+ db.delete("docs", "a") # TypeError: a str IS a Sequence[str] — this asked to
268
+ # delete the ids "a"... one character at a time
269
+ db.search(query=vec, scope="docs") # TypeError — same slip, five collections that
270
+ # do not exist, an empty result and a 200
271
+ f.in_("lang", "rust") # TypeError — one predicate value per character
272
+ db.delete_where("docs", []) # ValueError: an empty filter matches EVERYTHING, so this
273
+ # deleted the whole collection; use drop_collection
274
+ ```
275
+
276
+ None of these raise anywhere else in the stack: `mypy --strict` accepts all four, and the
277
+ server answers `200`. The list forms (`["a"]`, `["docs"]`, `["rust"]`) are what was meant.
278
+
279
+ Vectors, conversely, are *accepted* more widely than JSON allows: elements are coerced with
280
+ `float()`, so `numpy` arrays (`np.float32` is not a `float` subclass and `json` refuses it),
281
+ torch scalars and `Decimal` all work without a `.tolist()` first.
282
+
283
+ ## Bulk ingest: supply a pooled transport
284
+
285
+ The honest cost of a standard-library-only client: `urllib.request` opens a **fresh
286
+ connection per request**. For interactive use that is invisible; for a long run of
287
+ sequential upserts the handshakes are measurable overhead.
288
+
289
+ The escape hatch is `transport=` — a callable
290
+ `(method, url, headers, body, timeout) -> (status, text)`. Hand in one backed by `httpx`
291
+ or `requests` and you get pooling, keep-alive, retries, or instrumentation without the
292
+ SDK taking on a dependency for everyone:
293
+
294
+ ```python
295
+ import httpx
296
+ from nidus import NidusClient
297
+
298
+ class PooledTransport:
299
+ """A connection-pooling transport for bulk ingest."""
300
+
301
+ def __init__(self) -> None:
302
+ self._client = httpx.Client()
303
+
304
+ def __call__(self, method, url, headers, body, timeout):
305
+ # A transport RETURNS non-2xx statuses; only a failure to get any response
306
+ # at all should raise. httpx already behaves that way.
307
+ r = self._client.request(method, url, content=body, headers=headers, timeout=timeout)
308
+ return r.status_code, r.text
309
+
310
+ def close(self) -> None:
311
+ # NidusClient.close() calls close() on the transport if it has one, so
312
+ # `with NidusClient(...)` shuts the pool down too.
313
+ self._client.close()
314
+
315
+ with NidusClient("http://127.0.0.1:7700", transport=PooledTransport()) as db:
316
+ for batch in batches:
317
+ db.upsert("docs", batch)
318
+ ```
319
+
320
+ The same seam is what lets the SDK's own unit tests exercise every endpoint with no
321
+ server and no socket. `AsyncNidusClient` takes the natural equivalent for its own stack —
322
+ `transport=` there is an `httpx.AsyncBaseTransport` (a pre-tuned pool, or an
323
+ `httpx.MockTransport` for tests); it pools by default, so nothing extra is needed for
324
+ bulk ingest.
325
+
326
+ ## Errors
327
+
328
+ A failed request raises `NidusError` carrying the HTTP status the server reported, so
329
+ you can tell a client fault from a server fault:
330
+
331
+ ```python
332
+ from nidus import NidusError
333
+
334
+ try:
335
+ db.upsert("docs", records)
336
+ except NidusError as err:
337
+ if err.is_bad_request: # 400 — e.g. a vector dimension mismatch
338
+ ...
339
+ if err.is_locked: # 409 — the writer lock is held by another process
340
+ ...
341
+ print(err.status, err.message)
342
+ ```
343
+
344
+ Also available: `is_read_only` (403), `is_out_of_capacity` (507 — `max_vector_bytes`
345
+ exceeded, or OOM), and `is_transport_error`.
346
+
347
+ A status of `0` is the sentinel for **no response at all** — connection refused, DNS
348
+ failure, or the request exceeded `timeout`. Every nidus SDK uses the same sentinel, so
349
+ "was this even reachable?" is answered identically in all of them.
350
+
351
+ Value errors are raised locally, before any request: a `float` attribute or a
352
+ non-string list element is a `TypeError`, and an integer outside `i64` is a `ValueError`
353
+ (Python's ints are unbounded; the store's `Int` is not).
354
+
355
+ ## Documentation
356
+
357
+ Full documentation: <https://nidus.duckedup.org/sdks/python/>
358
+
359
+ ## License
360
+
361
+ MIT