LuxirClient
A lightweight Python client for Luxir, a hybrid search engine (BM25 + vector + facets, all in one request). This client talks to Luxir's HTTP API.
Installation
pip install luxir-client
It depends only on requests. To install from a local checkout instead
(e.g. for development):
pip install -e /path/to/LuxirClient
Quickstart
from luxir_client import LuxirClient
client = LuxirClient("http://localhost:9400")
# Collections auto-create on first write — no setup step required.
client.index("books", [
{"id": "b1", "title_t": "Dune", "year_i": 1965},
{"id": "b2", "title_t": "Dune Messiah", "year_i": 1969},
])
res = client.search("books", {
"query": "title_t:dune",
"get_number": True,
"get_scores": True,
})
print(res.get_num_found()) # 2
print(res.get_docs()) # [{'id': 'b1', ...}, {'id': 'b2', ...}]
Concepts
A few Luxir ideas that shape how you'll use this client:
-
Collections auto-create. There's no required "create collection" step — the first
index()call into a new name creates it. Useclient.collections.create(...)only when you want to install a schema before any documents land. -
Field names carry their type. Luxir infers a field's type from a naming suffix instead of requiring an upfront schema:
Suffix Meaning _tfull-text, stemmed/analyzed _sexact-match string _i/_isint, single or multi-valued _f/_fsfloat _dtdate _vvector _nametext with folding, no stemming So
{"id": "b1", "title_t": "Dune", "year_i": 1965}is enough — no schema declaration needed. Declare one later withclient.schemaif you want tighter control. -
Queries are either strings or structured dicts.
"query": "title_t:dune AND year_i:>=1960"and"query": {"match": {"field": "title_t", "val": "dune"}}are both valid — this client just passes whatever you give it straight through as JSON. -
Facets and metrics live under
ops, not a separate facet param. Search, facets, and analytics all execute in a single request.
Guide
Connecting
client = LuxirClient("http://localhost:9400")
Pass a list of hosts to get automatic failover to the next host if one is unreachable:
client = LuxirClient(["http://search-a:9400", "http://search-b:9400"])
Collections
client.collections.list() # ['books', 'main']
client.collections.exists("books") # True
client.collections.create("reviews") # explicit create, no schema
client.collections.create("reviews", schema={...}) # create with a schema
client.collections.delete("reviews")
client.collections.stats("books") # per-collection stats
client.collections.stats("books", segments=True)
Schema
client.schema.get("books") # authored schema
client.schema.get("books", resolved=True) # expanded/physical view
client.schema.set_fields("books", {
"title": {"type": "text", "stored": True},
})
client.schema.replace_all("books", {"fields": {...}})
client.schema.does_field_exist("books", "title_t")
Indexing
# Convenience wrapper — commits by default.
client.index("books", [
{"id": "b1", "title_t": "Dune", "year_i": 1965},
])
# Index without committing immediately (visibility delayed).
client.index("books", docs, commit=False)
# Full control via update(): mix upserts and deletes in one request.
client.update("books",
docs=[{"id": "b3", "title_t": "Children of Dune"}],
delete_ids=["b1"],
commit={},
)
Searching
res = client.search("books", {
"query": "title_t:(dune OR messiah) AND year_i:>=1965",
"filter": ["stock_i:>0"], # non-scoring, ANDed with query
"limit": 10,
"get_number": True, # populates res.get_num_found()
"get_scores": True, # populates res.get_scores()
"fields": ["id", "title_t", "year_i"],
})
res.get_docs() # list of doc dicts
res.get_results_count() # len(docs) in this response
res.get_num_found() # exact total match count (needs get_number=True)
res.get_scores() # {doc_id: score} (needs get_scores=True)
res.get_json() # raw response as a JSON string
Omit "query" entirely to match every document (useful for
count-only or facet-only requests with "limit": 0).
search_raw() returns the plain parsed dict instead of a
LuxirResponse, if you'd rather work with it directly.
Faceting
Facets are requested under ops and read back the same way:
res = client.search("books", {
"limit": 0, # don't need docs, just the facet
"ops": {
"by_genre": {"field_facet": {"field": "genre_s"}},
},
})
res.get_facet_buckets("by_genre")
# [{'val': 'scifi', 'count': 2}, {'val': 'romance', 'count': 1}]
res.get_ops() # the whole `ops` block
res.get_ops("by_genre") # just this op's result
Range facets, date histograms, and per-bucket metrics (avg, min,
...) all go through the same ops mechanism — see Luxir's faceting
docs for the full
request shapes. This client doesn't wrap them individually; pass
whatever ops dict you need.
Fetching a single document
doc = client.get("books", "b1") # raises NotFoundError if missing
Luxir has no dedicated realtime-get endpoint, so this is sugar over a
search() filtered to that id — not a separate, cheaper code path.
Deleting
client.delete_by_id("books", "b1") # single id
client.delete_by_id("books", ["b1", "b2"]) # multiple ids
client.delete_by_id("books", "b1", commit=False)
There's no delete_by_query — Luxir doesn't implement one.
Stats and health
client.health() # {'status': 'ok'}
client.stats() # node-wide stats
client.stats("books") # per-collection stats
Error handling
Every failure — whether the server returned an HTTP error status or a
200 with an error embedded in the body (Luxir does both, depending on
the endpoint) — raises a typed exception from luxir_client.exceptions,
all inheriting from LuxirError:
from luxir_client.exceptions import NotFoundError, AlreadyExistsError
try:
client.search("does_not_exist", {})
except NotFoundError as e:
print(e.kind, e.code, e.message)
# not_found collection_not_found "collection 'does_not_exist' does not exist"
| Exception | Luxir kind |
Typical cause |
|---|---|---|
InvalidRequestError |
invalid_request |
Malformed query/payload |
NotFoundError |
not_found |
Missing collection/document |
AlreadyExistsError |
already_exists |
Duplicate collection create |
FailedPreconditionError |
failed_precondition |
State conflict |
ResourceExhaustedError |
resource_exhausted |
Rate-limited (HTTP 429) — safe to retry with backoff |
UnavailableError |
unavailable |
Server temporarily unavailable — safe to retry |
InternalError |
internal |
Server-side bug |
ConnectionError |
— | Couldn't reach any configured host at all |
An unrecognized kind still raises, as the base LuxirError, so new
server-side error kinds fail loudly instead of silently.
Gotchas
Things confirmed by hand against a live instance that are easy to trip over:
"query": "*"is not match-all. Omit thequerykey entirely to match everything. A bare*raisesInvalidRequestError(unfielded term).- Search errors can come back as HTTP 200. The error envelope
(
{"error": {...}}) can be embedded in a 200 response body just as often as a 4xx/5xx status — this client checks for theerrorkey before looking at the status code, so you don't need to handle this yourself, but don't assumeresponse.status_code == 200means success if you're bypassing the client. collections.list()'s response key (collections) isn't documented, just inferred from behavior — flag it if a future Luxir version changes the field name.
What's not here yet
This is deliberately a lightweight first cut. Not implemented, add as needed:
- gRPC transport (
luxir.Indexer/luxir.Searcher/luxir.Admin) - NDJSON bulk streaming for indexing (currently one bounded JSON
request per
update()/index()call) - A structured query-builder helper (
Q.match(...),Q.boolean(...), etc.) - kind-aware retry/backoff (currently: no automatic retries at all, other than failing over to the next host on connection errors)
- An async client
Development
pip install -e ".[test]"
pytest
Tests run against a mocked requests.Session — no live Luxir instance
required.
Release files for luxir-client 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| luxir_client-0.1.1.tar.gz | 19.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| luxir_client-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 36.8 kB
Release files / luxir_client-0.1.1.tar.gz
| Download URL | luxir_client-0.1.1.tar.gz |
|---|---|
| Size | 19.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
18047a877833b4e8cb1fdb34b654ece8037218cc396c6244b860c884ede3ec8d
|
|
BLAKE2b-256 checksum How to use checksums |
03b72a146d424a936a366ff9e61cb4f04638108edd9e1e9fdca37cf78b6f34b1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / luxir_client-0.1.1-py3-none-any.whl
| Download URL | luxir_client-0.1.1-py3-none-any.whl |
|---|---|
| Size | 17.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2ef56f462c34b3a7a8fecaf853ced804adc1c21ee02743c48fcb6cfa0935ff95
|
|
BLAKE2b-256 checksum How to use checksums |
899513773be27e1d4a6f05b5d4b04cdef84704d91876165f7be268b1d47520fb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|