massengine 1.1.3__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.
- massengine/__init__.py +19 -0
- massengine/client.py +537 -0
- massengine/py.typed +0 -0
- massengine-1.1.3.dist-info/METADATA +89 -0
- massengine-1.1.3.dist-info/RECORD +7 -0
- massengine-1.1.3.dist-info/WHEEL +4 -0
- massengine-1.1.3.dist-info/licenses/LICENSE +17 -0
massengine/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Official Python client SDK for MassEngine."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
MassEngineClient,
|
|
5
|
+
MassEngineError,
|
|
6
|
+
Doc,
|
|
7
|
+
SDK_VERSION,
|
|
8
|
+
SearchRequest,
|
|
9
|
+
TenantQuota,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"MassEngineClient",
|
|
14
|
+
"MassEngineError",
|
|
15
|
+
"Doc",
|
|
16
|
+
"SearchRequest",
|
|
17
|
+
"TenantQuota",
|
|
18
|
+
]
|
|
19
|
+
__version__ = SDK_VERSION
|
massengine/client.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
"""Zero-dependency Python HTTP/JSON client for MassEngine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
import ssl
|
|
8
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Union
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
SDK_VERSION = "1.1.3"
|
|
14
|
+
USER_AGENT = f"massengine-python/{SDK_VERSION}"
|
|
15
|
+
DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024 # 64 MiB
|
|
16
|
+
MAX_ERROR_RESPONSE_BYTES = 65536 # 64 KiB
|
|
17
|
+
_MAX_LINE_READ_CHUNK = 4 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MassEngineError(Exception):
|
|
21
|
+
"""Raised when the MassEngine gateway returns a non-2xx HTTP status."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, status_code: int, message: str) -> None:
|
|
24
|
+
super().__init__(f"massengine api (status {status_code}): {message}")
|
|
25
|
+
self.status_code = status_code
|
|
26
|
+
self.message = message
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
30
|
+
"""Prevents urllib from following HTTP redirects and leaking Authorization headers."""
|
|
31
|
+
|
|
32
|
+
def redirect_request(
|
|
33
|
+
self,
|
|
34
|
+
req: urllib.request.Request,
|
|
35
|
+
fp: Any,
|
|
36
|
+
code: int,
|
|
37
|
+
msg: str,
|
|
38
|
+
headers: Any,
|
|
39
|
+
newurl: str,
|
|
40
|
+
) -> None:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Doc:
|
|
46
|
+
"""Represents a vector document for ingestion or export."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
vector: Sequence[float]
|
|
50
|
+
date: Optional[int] = None
|
|
51
|
+
category: Optional[int] = None
|
|
52
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
55
|
+
out: Dict[str, Any] = {
|
|
56
|
+
"id": self.id,
|
|
57
|
+
"vector": list(self.vector),
|
|
58
|
+
}
|
|
59
|
+
if self.date is not None:
|
|
60
|
+
out["date"] = int(self.date)
|
|
61
|
+
if self.category is not None:
|
|
62
|
+
out["category"] = int(self.category)
|
|
63
|
+
if self.metadata is not None:
|
|
64
|
+
out["metadata"] = self.metadata
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SearchRequest:
|
|
70
|
+
"""Specifies a single vector, lexical, or hybrid query."""
|
|
71
|
+
|
|
72
|
+
tenant: str
|
|
73
|
+
k: int = 10
|
|
74
|
+
vector: Optional[Sequence[float]] = None
|
|
75
|
+
query_text: Optional[str] = None
|
|
76
|
+
hybrid_alpha: Optional[float] = None
|
|
77
|
+
metadata_filter: Optional[Dict[str, Any]] = None
|
|
78
|
+
offset: int = 0
|
|
79
|
+
min_score: Optional[float] = None
|
|
80
|
+
rerank_pool: Optional[int] = None
|
|
81
|
+
nprobe: Optional[int] = None
|
|
82
|
+
min_date: Optional[int] = None
|
|
83
|
+
max_date: Optional[int] = None
|
|
84
|
+
category: Optional[int] = None
|
|
85
|
+
categories: Optional[Sequence[int]] = None
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
88
|
+
out: Dict[str, Any] = {
|
|
89
|
+
"tenant": self.tenant,
|
|
90
|
+
"k": self.k,
|
|
91
|
+
}
|
|
92
|
+
if self.vector is not None:
|
|
93
|
+
out["vector"] = list(self.vector)
|
|
94
|
+
if self.query_text:
|
|
95
|
+
out["query_text"] = self.query_text
|
|
96
|
+
if self.hybrid_alpha is not None:
|
|
97
|
+
out["hybrid_alpha"] = float(self.hybrid_alpha)
|
|
98
|
+
if self.metadata_filter is not None:
|
|
99
|
+
out["metadata_filter"] = self.metadata_filter
|
|
100
|
+
if self.offset:
|
|
101
|
+
out["offset"] = int(self.offset)
|
|
102
|
+
if self.min_score is not None:
|
|
103
|
+
out["min_score"] = float(self.min_score)
|
|
104
|
+
if self.rerank_pool:
|
|
105
|
+
out["rerank_pool"] = int(self.rerank_pool)
|
|
106
|
+
if self.nprobe:
|
|
107
|
+
out["nprobe"] = int(self.nprobe)
|
|
108
|
+
if self.min_date is not None:
|
|
109
|
+
out["min_date"] = int(self.min_date)
|
|
110
|
+
if self.max_date is not None:
|
|
111
|
+
out["max_date"] = int(self.max_date)
|
|
112
|
+
if self.category is not None:
|
|
113
|
+
out["category"] = int(self.category)
|
|
114
|
+
if self.categories:
|
|
115
|
+
out["categories"] = [int(c) for c in self.categories]
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class TenantQuota:
|
|
121
|
+
"""Configures a tenant's commercial plan tier, limits, and suspension status."""
|
|
122
|
+
|
|
123
|
+
plan: str = ""
|
|
124
|
+
max_docs: int = 0
|
|
125
|
+
max_qps: float = 0.0
|
|
126
|
+
burst: int = 0
|
|
127
|
+
suspended: bool = False
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
130
|
+
return {
|
|
131
|
+
"plan": self.plan,
|
|
132
|
+
"max_docs": int(self.max_docs),
|
|
133
|
+
"max_qps": float(self.max_qps),
|
|
134
|
+
"burst": int(self.burst),
|
|
135
|
+
"suspended": bool(self.suspended),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class MassEngineClient:
|
|
140
|
+
"""Official Python SDK client for the MassEngine HTTP REST API."""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
base_url: str = "http://localhost:8081",
|
|
145
|
+
api_key: Optional[str] = None,
|
|
146
|
+
timeout: float = 30.0,
|
|
147
|
+
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
|
|
148
|
+
) -> None:
|
|
149
|
+
self.base_url = self._validate_base_url(base_url)
|
|
150
|
+
self._api_key = self._validate_api_key(api_key)
|
|
151
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
152
|
+
raise ValueError("timeout must be a positive number")
|
|
153
|
+
if isinstance(max_response_bytes, bool) or not isinstance(max_response_bytes, int) or max_response_bytes <= 0:
|
|
154
|
+
raise ValueError("max_response_bytes must be a positive integer")
|
|
155
|
+
self.timeout = float(timeout)
|
|
156
|
+
self.max_response_bytes = max_response_bytes
|
|
157
|
+
|
|
158
|
+
ssl_ctx = ssl.create_default_context()
|
|
159
|
+
ssl_ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
160
|
+
self._opener = urllib.request.build_opener(
|
|
161
|
+
_NoRedirectHandler(),
|
|
162
|
+
urllib.request.HTTPSHandler(context=ssl_ctx),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def __repr__(self) -> str:
|
|
166
|
+
masked = "***" if self._api_key else None
|
|
167
|
+
return f"MassEngineClient(base_url={self.base_url!r}, api_key={masked!r})"
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _validate_base_url(base_url: str) -> str:
|
|
171
|
+
if not isinstance(base_url, str) or not base_url.strip():
|
|
172
|
+
raise ValueError("base_url must be a valid http or https URL")
|
|
173
|
+
cleaned = base_url.strip().rstrip("/")
|
|
174
|
+
if any(ord(ch) <= 0x20 or ord(ch) == 0x7F for ch in cleaned):
|
|
175
|
+
raise ValueError("base_url contains invalid characters")
|
|
176
|
+
parsed = urllib.parse.urlsplit(cleaned)
|
|
177
|
+
if (
|
|
178
|
+
parsed.scheme not in ("http", "https")
|
|
179
|
+
or not parsed.netloc
|
|
180
|
+
or parsed.username is not None
|
|
181
|
+
or parsed.password is not None
|
|
182
|
+
or parsed.query
|
|
183
|
+
or parsed.fragment
|
|
184
|
+
):
|
|
185
|
+
raise ValueError("base_url must use http or https scheme with a valid host")
|
|
186
|
+
return cleaned
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _validate_api_key(api_key: Optional[str]) -> Optional[str]:
|
|
190
|
+
if api_key is None or api_key == "":
|
|
191
|
+
return None
|
|
192
|
+
if not isinstance(api_key, str):
|
|
193
|
+
raise ValueError("invalid api_key format")
|
|
194
|
+
trimmed = api_key.strip(" \t")
|
|
195
|
+
if not trimmed or not all(0x21 <= ord(ch) <= 0x7E for ch in trimmed):
|
|
196
|
+
raise ValueError("invalid api_key format")
|
|
197
|
+
return trimmed
|
|
198
|
+
|
|
199
|
+
def _headers(self, content_type: Optional[str] = "application/json") -> Dict[str, str]:
|
|
200
|
+
hdrs: Dict[str, str] = {"User-Agent": USER_AGENT}
|
|
201
|
+
if content_type:
|
|
202
|
+
hdrs["Content-Type"] = content_type
|
|
203
|
+
if self._api_key:
|
|
204
|
+
hdrs["Authorization"] = f"Bearer {self._api_key}"
|
|
205
|
+
return hdrs
|
|
206
|
+
|
|
207
|
+
def _read_bounded(self, resp: Any) -> bytes:
|
|
208
|
+
raw: bytes = resp.read(self.max_response_bytes + 1)
|
|
209
|
+
if len(raw) > self.max_response_bytes:
|
|
210
|
+
status = int(getattr(resp, "status", 200) or 200)
|
|
211
|
+
raise MassEngineError(
|
|
212
|
+
status,
|
|
213
|
+
f"response body exceeds max_response_bytes ({self.max_response_bytes})",
|
|
214
|
+
)
|
|
215
|
+
return raw
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _decode_error(e: urllib.error.HTTPError) -> MassEngineError:
|
|
219
|
+
try:
|
|
220
|
+
raw_err = e.read(MAX_ERROR_RESPONSE_BYTES).decode("utf-8", errors="replace")
|
|
221
|
+
finally:
|
|
222
|
+
try:
|
|
223
|
+
e.close()
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
msg = raw_err.strip()
|
|
227
|
+
try:
|
|
228
|
+
parsed = json.loads(raw_err)
|
|
229
|
+
if isinstance(parsed, dict) and parsed.get("error"):
|
|
230
|
+
msg = str(parsed["error"])
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
return MassEngineError(e.code, msg)
|
|
234
|
+
|
|
235
|
+
def _request_json(
|
|
236
|
+
self,
|
|
237
|
+
method: str,
|
|
238
|
+
path: str,
|
|
239
|
+
body: Optional[Any] = None,
|
|
240
|
+
) -> Any:
|
|
241
|
+
data: Optional[bytes] = None
|
|
242
|
+
content_type: Optional[str] = None
|
|
243
|
+
if body is not None:
|
|
244
|
+
data = json.dumps(body).encode("utf-8")
|
|
245
|
+
content_type = "application/json"
|
|
246
|
+
|
|
247
|
+
req = urllib.request.Request(
|
|
248
|
+
url=f"{self.base_url}{path}",
|
|
249
|
+
data=data,
|
|
250
|
+
headers=self._headers(content_type),
|
|
251
|
+
method=method,
|
|
252
|
+
)
|
|
253
|
+
try:
|
|
254
|
+
with self._opener.open(req, timeout=self.timeout) as resp:
|
|
255
|
+
status = int(getattr(resp, "status", 200) or 200)
|
|
256
|
+
raw = self._read_bounded(resp)
|
|
257
|
+
if status < 200 or status >= 300:
|
|
258
|
+
raise MassEngineError(status, raw.decode("utf-8", errors="replace").strip())
|
|
259
|
+
if not raw:
|
|
260
|
+
return None
|
|
261
|
+
return json.loads(raw.decode("utf-8"))
|
|
262
|
+
except urllib.error.HTTPError as e:
|
|
263
|
+
raise self._decode_error(e) from None
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _esc(segment: str) -> str:
|
|
267
|
+
if not isinstance(segment, str) or segment in ("", ".", ".."):
|
|
268
|
+
raise ValueError("invalid URL path segment")
|
|
269
|
+
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in segment):
|
|
270
|
+
raise ValueError("invalid control character in URL path segment")
|
|
271
|
+
return urllib.parse.quote(segment, safe="")
|
|
272
|
+
|
|
273
|
+
@classmethod
|
|
274
|
+
def _esc_tenant(cls, tenant: str) -> str:
|
|
275
|
+
if not isinstance(tenant, str) or "/" in tenant or "\\" in tenant:
|
|
276
|
+
raise ValueError("invalid tenant identifier")
|
|
277
|
+
return cls._esc(tenant)
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def _esc_doc_id(cls, doc_id: str) -> str:
|
|
281
|
+
if doc_id == "batch-get":
|
|
282
|
+
raise ValueError("reserved document identifier")
|
|
283
|
+
return cls._esc(doc_id)
|
|
284
|
+
|
|
285
|
+
def ingest(
|
|
286
|
+
self,
|
|
287
|
+
tenant: str,
|
|
288
|
+
docs: Sequence[Union[Doc, Dict[str, Any]]],
|
|
289
|
+
) -> Dict[str, Any]:
|
|
290
|
+
"""Inserts or upserts a batch of documents into `tenant`."""
|
|
291
|
+
serialized = [d.to_dict() if isinstance(d, Doc) else d for d in docs]
|
|
292
|
+
return self._request_json(
|
|
293
|
+
"POST",
|
|
294
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/ingest",
|
|
295
|
+
{"docs": serialized},
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def search(
|
|
299
|
+
self,
|
|
300
|
+
tenant: str,
|
|
301
|
+
*,
|
|
302
|
+
vector: Optional[Sequence[float]] = None,
|
|
303
|
+
query_text: Optional[str] = None,
|
|
304
|
+
k: int = 10,
|
|
305
|
+
hybrid_alpha: Optional[float] = None,
|
|
306
|
+
metadata_filter: Optional[Dict[str, Any]] = None,
|
|
307
|
+
offset: int = 0,
|
|
308
|
+
min_score: Optional[float] = None,
|
|
309
|
+
rerank_pool: Optional[int] = None,
|
|
310
|
+
nprobe: Optional[int] = None,
|
|
311
|
+
min_date: Optional[int] = None,
|
|
312
|
+
max_date: Optional[int] = None,
|
|
313
|
+
category: Optional[int] = None,
|
|
314
|
+
categories: Optional[Sequence[int]] = None,
|
|
315
|
+
) -> Dict[str, Any]:
|
|
316
|
+
"""Executes a dense vector, lexical BM25, or hybrid query."""
|
|
317
|
+
sr = SearchRequest(
|
|
318
|
+
tenant=tenant,
|
|
319
|
+
k=k,
|
|
320
|
+
vector=vector,
|
|
321
|
+
query_text=query_text,
|
|
322
|
+
hybrid_alpha=hybrid_alpha,
|
|
323
|
+
metadata_filter=metadata_filter,
|
|
324
|
+
offset=offset,
|
|
325
|
+
min_score=min_score,
|
|
326
|
+
rerank_pool=rerank_pool,
|
|
327
|
+
nprobe=nprobe,
|
|
328
|
+
min_date=min_date,
|
|
329
|
+
max_date=max_date,
|
|
330
|
+
category=category,
|
|
331
|
+
categories=categories,
|
|
332
|
+
)
|
|
333
|
+
return self._request_json("POST", "/v1/search", sr.to_dict())
|
|
334
|
+
|
|
335
|
+
def batch_search(
|
|
336
|
+
self,
|
|
337
|
+
queries: Sequence[Union[SearchRequest, Dict[str, Any]]],
|
|
338
|
+
) -> Dict[str, Any]:
|
|
339
|
+
"""Executes multiple search queries in a single round-trip."""
|
|
340
|
+
serialized = [
|
|
341
|
+
q.to_dict() if isinstance(q, SearchRequest) else q for q in queries
|
|
342
|
+
]
|
|
343
|
+
return self._request_json("POST", "/v1/search/batch", {"queries": serialized})
|
|
344
|
+
|
|
345
|
+
def get_doc(self, tenant: str, doc_id: str) -> Dict[str, Any]:
|
|
346
|
+
"""Fetches a document's metadata by ID."""
|
|
347
|
+
return self._request_json(
|
|
348
|
+
"GET",
|
|
349
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/docs/{self._esc_doc_id(doc_id)}",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
def patch_doc(
|
|
353
|
+
self,
|
|
354
|
+
tenant: str,
|
|
355
|
+
doc_id: str,
|
|
356
|
+
*,
|
|
357
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
358
|
+
date: Optional[int] = None,
|
|
359
|
+
category: Optional[int] = None,
|
|
360
|
+
replace_metadata: bool = False,
|
|
361
|
+
) -> Dict[str, Any]:
|
|
362
|
+
"""Updates a document's metadata, date, or category in place without re-embedding."""
|
|
363
|
+
body: Dict[str, Any] = {}
|
|
364
|
+
if metadata is not None:
|
|
365
|
+
body["metadata"] = metadata
|
|
366
|
+
if date is not None:
|
|
367
|
+
body["date"] = int(date)
|
|
368
|
+
if category is not None:
|
|
369
|
+
body["category"] = int(category)
|
|
370
|
+
if replace_metadata:
|
|
371
|
+
body["replace_metadata"] = True
|
|
372
|
+
return self._request_json(
|
|
373
|
+
"PATCH",
|
|
374
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/docs/{self._esc_doc_id(doc_id)}",
|
|
375
|
+
body,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def batch_get_docs(self, tenant: str, ids: Sequence[str]) -> List[Dict[str, Any]]:
|
|
379
|
+
"""Fetches multiple documents by ID in a single request."""
|
|
380
|
+
resp = self._request_json(
|
|
381
|
+
"POST",
|
|
382
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/docs/batch-get",
|
|
383
|
+
{"ids": list(ids)},
|
|
384
|
+
)
|
|
385
|
+
return resp.get("docs", [])
|
|
386
|
+
|
|
387
|
+
def delete_doc(self, tenant: str, doc_id: str) -> Dict[str, Any]:
|
|
388
|
+
"""Deletes a document by ID."""
|
|
389
|
+
return self._request_json(
|
|
390
|
+
"DELETE",
|
|
391
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/docs/{self._esc_doc_id(doc_id)}",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def import_tenant(
|
|
395
|
+
self,
|
|
396
|
+
tenant: str,
|
|
397
|
+
docs: Iterable[Union[Doc, Dict[str, Any]]],
|
|
398
|
+
) -> int:
|
|
399
|
+
"""Streams a collection of documents into `tenant` via NDJSON."""
|
|
400
|
+
esc_tenant = self._esc_tenant(tenant)
|
|
401
|
+
lines = [
|
|
402
|
+
json.dumps(d.to_dict() if isinstance(d, Doc) else d) for d in docs
|
|
403
|
+
]
|
|
404
|
+
payload = ("\n".join(lines) + "\n").encode("utf-8")
|
|
405
|
+
req = urllib.request.Request(
|
|
406
|
+
url=f"{self.base_url}/v1/tenants/{esc_tenant}/import",
|
|
407
|
+
data=payload,
|
|
408
|
+
headers=self._headers("application/x-ndjson"),
|
|
409
|
+
method="POST",
|
|
410
|
+
)
|
|
411
|
+
try:
|
|
412
|
+
with self._opener.open(req, timeout=self.timeout) as resp:
|
|
413
|
+
raw = self._read_bounded(resp)
|
|
414
|
+
out = json.loads(raw.decode("utf-8"))
|
|
415
|
+
return int(out.get("imported", 0))
|
|
416
|
+
except urllib.error.HTTPError as e:
|
|
417
|
+
raise self._decode_error(e) from None
|
|
418
|
+
|
|
419
|
+
def export_tenant(self, tenant: str) -> List[Dict[str, Any]]:
|
|
420
|
+
"""Exports all live documents belonging to `tenant` via streaming NDJSON."""
|
|
421
|
+
esc_tenant = self._esc_tenant(tenant)
|
|
422
|
+
req = urllib.request.Request(
|
|
423
|
+
url=f"{self.base_url}/v1/tenants/{esc_tenant}/export",
|
|
424
|
+
headers=self._headers(None),
|
|
425
|
+
method="GET",
|
|
426
|
+
)
|
|
427
|
+
try:
|
|
428
|
+
with self._opener.open(req, timeout=self.timeout) as resp:
|
|
429
|
+
status = int(getattr(resp, "status", 200) or 200)
|
|
430
|
+
docs: List[Dict[str, Any]] = []
|
|
431
|
+
total_bytes = 0
|
|
432
|
+
buf = b""
|
|
433
|
+
while True:
|
|
434
|
+
remaining = self.max_response_bytes - total_bytes + 1
|
|
435
|
+
read_limit = min(remaining, _MAX_LINE_READ_CHUNK)
|
|
436
|
+
chunk: bytes = resp.readline(max(1, read_limit))
|
|
437
|
+
if not chunk:
|
|
438
|
+
break
|
|
439
|
+
total_bytes += len(chunk)
|
|
440
|
+
if total_bytes > self.max_response_bytes:
|
|
441
|
+
raise MassEngineError(
|
|
442
|
+
status,
|
|
443
|
+
f"response body exceeds max_response_bytes ({self.max_response_bytes})",
|
|
444
|
+
)
|
|
445
|
+
buf += chunk
|
|
446
|
+
if buf.endswith(b"\n"):
|
|
447
|
+
line = buf.decode("utf-8").strip()
|
|
448
|
+
buf = b""
|
|
449
|
+
if line:
|
|
450
|
+
docs.append(json.loads(line))
|
|
451
|
+
if buf:
|
|
452
|
+
line = buf.decode("utf-8").strip()
|
|
453
|
+
if line:
|
|
454
|
+
docs.append(json.loads(line))
|
|
455
|
+
return docs
|
|
456
|
+
except urllib.error.HTTPError as e:
|
|
457
|
+
raise self._decode_error(e) from None
|
|
458
|
+
|
|
459
|
+
def create_tenant_key(self, tenant: str, key: str = "") -> str:
|
|
460
|
+
"""Creates or registers a tenant-scoped RBAC API key (requires admin key)."""
|
|
461
|
+
resp = self._request_json(
|
|
462
|
+
"POST",
|
|
463
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/keys",
|
|
464
|
+
{"key": key},
|
|
465
|
+
)
|
|
466
|
+
return str(resp["key"])
|
|
467
|
+
|
|
468
|
+
def revoke_tenant_key(self, tenant: str, key: str) -> Dict[str, Any]:
|
|
469
|
+
"""Revokes a tenant-scoped RBAC API key (requires admin key)."""
|
|
470
|
+
return self._request_json(
|
|
471
|
+
"DELETE",
|
|
472
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/keys",
|
|
473
|
+
{"key": key},
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def set_tenant_quota(
|
|
477
|
+
self,
|
|
478
|
+
tenant: str,
|
|
479
|
+
quota: Union[TenantQuota, Dict[str, Any]],
|
|
480
|
+
) -> Dict[str, Any]:
|
|
481
|
+
"""Sets a tenant's plan tier, max_docs cap, max_qps limit, and suspension status."""
|
|
482
|
+
q_dict = quota.to_dict() if isinstance(quota, TenantQuota) else quota
|
|
483
|
+
resp = self._request_json(
|
|
484
|
+
"PUT",
|
|
485
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/quota",
|
|
486
|
+
q_dict,
|
|
487
|
+
)
|
|
488
|
+
return resp.get("quota", resp)
|
|
489
|
+
|
|
490
|
+
def get_tenant_quota(self, tenant: str) -> Dict[str, Any]:
|
|
491
|
+
"""Retrieves the active quota configuration for `tenant`."""
|
|
492
|
+
resp = self._request_json(
|
|
493
|
+
"GET",
|
|
494
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/quota",
|
|
495
|
+
)
|
|
496
|
+
return resp.get("quota", resp)
|
|
497
|
+
|
|
498
|
+
def get_tenant_usage(self, tenant: str) -> Dict[str, Any]:
|
|
499
|
+
"""Returns cumulative billable read/write counters and live doc count for `tenant`."""
|
|
500
|
+
return self._request_json(
|
|
501
|
+
"GET",
|
|
502
|
+
f"/v1/tenants/{self._esc_tenant(tenant)}/usage",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def list_usage(self) -> Dict[str, Any]:
|
|
506
|
+
"""Returns the cluster-wide billable usage report across all tenants (admin key)."""
|
|
507
|
+
return self._request_json("GET", "/v1/usage")
|
|
508
|
+
|
|
509
|
+
def list_tenants(self) -> List[Dict[str, Any]]:
|
|
510
|
+
"""Lists all known tenants sorted by name (admin key)."""
|
|
511
|
+
resp = self._request_json("GET", "/v1/tenants")
|
|
512
|
+
return resp.get("tenants", [])
|
|
513
|
+
|
|
514
|
+
def get_tenant(self, tenant: str) -> Dict[str, Any]:
|
|
515
|
+
"""Returns document and storage volume metadata for `tenant`."""
|
|
516
|
+
return self._request_json("GET", f"/v1/tenants/{self._esc_tenant(tenant)}")
|
|
517
|
+
|
|
518
|
+
def delete_tenant(self, tenant: str) -> int:
|
|
519
|
+
"""Deletes all documents and state for `tenant` (admin key)."""
|
|
520
|
+
resp = self._request_json("DELETE", f"/v1/tenants/{self._esc_tenant(tenant)}")
|
|
521
|
+
return int(resp.get("docs_deleted", 0))
|
|
522
|
+
|
|
523
|
+
def flush(self) -> Dict[str, Any]:
|
|
524
|
+
"""Persists buffered writes to durable storage (admin key)."""
|
|
525
|
+
return self._request_json("POST", "/v1/flush")
|
|
526
|
+
|
|
527
|
+
def compact(self) -> Dict[str, Any]:
|
|
528
|
+
"""Optimizes storage segments (admin key)."""
|
|
529
|
+
return self._request_json("POST", "/v1/compact")
|
|
530
|
+
|
|
531
|
+
def ready(self) -> Dict[str, Any]:
|
|
532
|
+
"""Checks service readiness."""
|
|
533
|
+
return self._request_json("GET", "/v1/ready")
|
|
534
|
+
|
|
535
|
+
def health(self) -> Dict[str, Any]:
|
|
536
|
+
"""Checks gateway health."""
|
|
537
|
+
return self._request_json("GET", "/v1/health")
|
massengine/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: massengine
|
|
3
|
+
Version: 1.1.3
|
|
4
|
+
Summary: Official Python SDK for the MassEngine vector and hybrid search database.
|
|
5
|
+
Project-URL: Homepage, https://massengine.com
|
|
6
|
+
Project-URL: Documentation, https://massengine.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/massengine-dev/massengine-python
|
|
8
|
+
Author-email: MassEngine <dev@massengine.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bm25,embeddings,hybrid-search,massengine,rag,vector-database
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# MassEngine Python SDK (`massengine`)
|
|
26
|
+
|
|
27
|
+
Official zero-dependency Python client for [MassEngine](https://massengine.com) — the sub-5ms filtered and hybrid vector search database.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install massengine
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from massengine import MassEngineClient, Doc
|
|
39
|
+
|
|
40
|
+
# Connect to a local quickstart cluster (http://localhost:8080) or cloud gateway:
|
|
41
|
+
client = MassEngineClient(
|
|
42
|
+
base_url="http://localhost:8080",
|
|
43
|
+
api_key="mk_your_tenant_or_meadm_admin_key",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# 1. Ingest vectors + structured metadata
|
|
47
|
+
client.ingest(
|
|
48
|
+
"acme-prod",
|
|
49
|
+
[
|
|
50
|
+
Doc(
|
|
51
|
+
id="doc-1",
|
|
52
|
+
vector=[0.12, -0.44, 0.88, 0.05],
|
|
53
|
+
category=1,
|
|
54
|
+
metadata={"department": "security", "clearance": 3, "tags": ["soc2", "policy"]},
|
|
55
|
+
)
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# 2. Hybrid dense + BM25 search with rich metadata filter operators
|
|
60
|
+
res = client.search(
|
|
61
|
+
"acme-prod",
|
|
62
|
+
vector=[0.12, -0.44, 0.88, 0.05],
|
|
63
|
+
query_text="soc2 policy",
|
|
64
|
+
hybrid_alpha=0.7,
|
|
65
|
+
k=10,
|
|
66
|
+
min_score=0.5,
|
|
67
|
+
metadata_filter={
|
|
68
|
+
"department": {"$in": ["security", "legal"]},
|
|
69
|
+
"clearance": {"$gte": 2},
|
|
70
|
+
"tags": {"$contains": "soc2"},
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
print(res["hits"])
|
|
74
|
+
|
|
75
|
+
# 3. In-place metadata patching without re-embedding
|
|
76
|
+
client.patch_doc(
|
|
77
|
+
"acme-prod",
|
|
78
|
+
"doc-1",
|
|
79
|
+
metadata={"clearance": 4, "reviewed": True},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# 4. Inspect tenant quota & billable usage
|
|
83
|
+
usage = client.get_tenant_usage("acme-prod")
|
|
84
|
+
print(usage)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
Apache License 2.0
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
massengine/__init__.py,sha256=ucdvnZ0s99EHu7jNyPvEYh3ABn-21A1JRtYowez0FDA,318
|
|
2
|
+
massengine/client.py,sha256=7tVGtavgVZ9Ean_PdKYVoEf3Blnry9bULY575KOA_4I,19696
|
|
3
|
+
massengine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
massengine-1.1.3.dist-info/METADATA,sha256=SIzfvEhE9pwzTZU0oKO46WbQa1bycHH0bFHrcubnDYo,2507
|
|
5
|
+
massengine-1.1.3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
massengine-1.1.3.dist-info/licenses/LICENSE,sha256=qlLH60XoaEfCBv6y6sFeyM1RI1UXKz1dXxCDOGmA2Vk,625
|
|
7
|
+
massengine-1.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026 MassEngine
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|