massengine 1.1.3__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
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .venv/
9
+ venv/
10
+ .DS_Store
@@ -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.
@@ -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,65 @@
1
+ # MassEngine Python SDK (`massengine`)
2
+
3
+ Official zero-dependency Python client for [MassEngine](https://massengine.com) — the sub-5ms filtered and hybrid vector search database.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install massengine
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from massengine import MassEngineClient, Doc
15
+
16
+ # Connect to a local quickstart cluster (http://localhost:8080) or cloud gateway:
17
+ client = MassEngineClient(
18
+ base_url="http://localhost:8080",
19
+ api_key="mk_your_tenant_or_meadm_admin_key",
20
+ )
21
+
22
+ # 1. Ingest vectors + structured metadata
23
+ client.ingest(
24
+ "acme-prod",
25
+ [
26
+ Doc(
27
+ id="doc-1",
28
+ vector=[0.12, -0.44, 0.88, 0.05],
29
+ category=1,
30
+ metadata={"department": "security", "clearance": 3, "tags": ["soc2", "policy"]},
31
+ )
32
+ ],
33
+ )
34
+
35
+ # 2. Hybrid dense + BM25 search with rich metadata filter operators
36
+ res = client.search(
37
+ "acme-prod",
38
+ vector=[0.12, -0.44, 0.88, 0.05],
39
+ query_text="soc2 policy",
40
+ hybrid_alpha=0.7,
41
+ k=10,
42
+ min_score=0.5,
43
+ metadata_filter={
44
+ "department": {"$in": ["security", "legal"]},
45
+ "clearance": {"$gte": 2},
46
+ "tags": {"$contains": "soc2"},
47
+ },
48
+ )
49
+ print(res["hits"])
50
+
51
+ # 3. In-place metadata patching without re-embedding
52
+ client.patch_doc(
53
+ "acme-prod",
54
+ "doc-1",
55
+ metadata={"clearance": 4, "reviewed": True},
56
+ )
57
+
58
+ # 4. Inspect tenant quota & billable usage
59
+ usage = client.get_tenant_usage("acme-prod")
60
+ print(usage)
61
+ ```
62
+
63
+ ## License
64
+
65
+ Apache License 2.0
@@ -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
@@ -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")
File without changes
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "massengine"
7
+ version = "1.1.3"
8
+ description = "Official Python SDK for the MassEngine vector and hybrid search database."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [
14
+ { name = "MassEngine", email = "dev@massengine.com" }
15
+ ]
16
+ keywords = ["vector-database", "hybrid-search", "rag", "embeddings", "bm25", "massengine"]
17
+ classifiers = [
18
+ "Development Status :: 5 - Production/Stable",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Database",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = []
30
+
31
+ [project.urls]
32
+ Homepage = "https://massengine.com"
33
+ Documentation = "https://massengine.com/docs"
34
+ Repository = "https://github.com/massengine-dev/massengine-python"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["massengine"]
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = [
41
+ "/massengine",
42
+ "/tests",
43
+ "/LICENSE",
44
+ "/README.md",
45
+ "/pyproject.toml",
46
+ ]
@@ -0,0 +1,237 @@
1
+ """Self-contained unit & integration test suite for the MassEngine Python SDK."""
2
+
3
+ from http.server import BaseHTTPRequestHandler, HTTPServer
4
+ import json
5
+ import socket
6
+ import threading
7
+ import time
8
+ import unittest
9
+
10
+ from massengine import Doc, MassEngineClient, MassEngineError, SearchRequest, TenantQuota
11
+
12
+
13
+ class _MockGatewayHandler(BaseHTTPRequestHandler):
14
+ last_user_agent: str = ""
15
+ redirect_target_url: str = ""
16
+
17
+ def log_message(self, format: str, *args: object) -> None:
18
+ return
19
+
20
+ def _send(self, code: int, payload: object, content_type: str = "application/json") -> None:
21
+ raw = payload if isinstance(payload, bytes) else json.dumps(payload).encode("utf-8")
22
+ self.send_response(code)
23
+ self.send_header("Content-Type", content_type)
24
+ self.send_header("Content-Length", str(len(raw)))
25
+ self.end_headers()
26
+ self.wfile.write(raw)
27
+
28
+ def do_GET(self) -> None:
29
+ _MockGatewayHandler.last_user_agent = self.headers.get("User-Agent", "")
30
+ auth = self.headers.get("Authorization", "")
31
+ if self.path == "/v1/health":
32
+ self._send(200, {"status": "ok"})
33
+ elif self.path == "/v1/ready":
34
+ self._send(200, {"ready": True})
35
+ elif self.path == "/v1/tenants/redirect-me":
36
+ self.send_response(302)
37
+ self.send_header("Location", _MockGatewayHandler.redirect_target_url)
38
+ self.send_header("Content-Length", "0")
39
+ self.end_headers()
40
+ elif self.path == "/v1/tenants/oversized":
41
+ self._send(200, {"tenant": "oversized", "pad": "x" * 2048})
42
+ elif self.path.endswith("/usage"):
43
+ self._send(200, {"tenant": "acme", "plan": "pro", "live_docs": 2, "read_units": 4, "write_units": 3})
44
+ elif self.path.endswith("/export"):
45
+ ndjson = b'{"id":"py-1","vector":[1.0,0.0]}\n{"id":"py-2","vector":[0.0,1.0]}\n'
46
+ self._send(200, ndjson, "application/x-ndjson")
47
+ elif self.path.endswith("/docs/py-1"):
48
+ if auth == "Bearer revoked":
49
+ self._send(401, {"error": "unauthorized"})
50
+ else:
51
+ self._send(200, {"id": "py-1", "tenant": "acme", "metadata": {"tier": "gold"}})
52
+ else:
53
+ self._send(404, {"error": "not found"})
54
+
55
+ def do_PUT(self) -> None:
56
+ length = int(self.headers.get("Content-Length", "0"))
57
+ body = json.loads(self.rfile.read(length).decode("utf-8"))
58
+ self._send(200, {"tenant": "acme", "quota": body})
59
+
60
+ def do_POST(self) -> None:
61
+ length = int(self.headers.get("Content-Length", "0"))
62
+ raw = self.rfile.read(length).decode("utf-8")
63
+ if self.path == "/v1/flush":
64
+ time.sleep(0.15)
65
+ self._send(200, {"flushed": True})
66
+ elif self.path.endswith("/keys"):
67
+ self._send(200, {"tenant": "acme", "key": "mk_test_key_123"})
68
+ elif self.path.endswith("/ingest"):
69
+ body = json.loads(raw)
70
+ self._send(200, {"ingested": len(body["docs"]), "degraded": False})
71
+ elif self.path == "/v1/search":
72
+ self._send(200, {"hits": [{"id": "py-1", "doc_id": 1, "score": 0.99, "shard": 0}], "took_ms": 1.2})
73
+ elif self.path == "/v1/search/batch":
74
+ self._send(200, {"results": [{"hits": [{"id": "py-1"}]}, {"hits": [{"id": "py-2"}]}]})
75
+ else:
76
+ self._send(200, {"ok": True})
77
+
78
+ def do_PATCH(self) -> None:
79
+ length = int(self.headers.get("Content-Length", "0"))
80
+ body = json.loads(self.rfile.read(length).decode("utf-8"))
81
+ self._send(200, {"id": "py-2", "category": body.get("category", 0), "metadata": body.get("metadata", {})})
82
+
83
+ def do_DELETE(self) -> None:
84
+ self._send(200, {"deleted": True, "docs_deleted": 2, "revoked": True})
85
+
86
+
87
+ class _RedirectTrapHandler(BaseHTTPRequestHandler):
88
+ contacted: bool = False
89
+ leaked_auth: str = ""
90
+
91
+ def log_message(self, format: str, *args: object) -> None:
92
+ return
93
+
94
+ def do_GET(self) -> None:
95
+ _RedirectTrapHandler.contacted = True
96
+ _RedirectTrapHandler.leaked_auth = self.headers.get("Authorization", "")
97
+ self.send_response(200)
98
+ self.end_headers()
99
+
100
+
101
+ class TestMassEngineClient(unittest.TestCase):
102
+ @classmethod
103
+ def setUpClass(cls) -> None:
104
+ cls.trap_httpd = HTTPServer(("127.0.0.1", 0), _RedirectTrapHandler)
105
+ trap_port = cls.trap_httpd.server_address[1]
106
+ _MockGatewayHandler.redirect_target_url = f"http://127.0.0.1:{trap_port}/steal"
107
+ cls.trap_thread = threading.Thread(target=cls.trap_httpd.serve_forever, daemon=True)
108
+ cls.trap_thread.start()
109
+
110
+ cls.httpd = HTTPServer(("127.0.0.1", 0), _MockGatewayHandler)
111
+ port = cls.httpd.server_address[1]
112
+ cls.base_url = f"http://127.0.0.1:{port}"
113
+ cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
114
+ cls.thread.start()
115
+
116
+ @classmethod
117
+ def tearDownClass(cls) -> None:
118
+ cls.httpd.shutdown()
119
+ cls.httpd.server_close()
120
+ cls.trap_httpd.shutdown()
121
+ cls.trap_httpd.server_close()
122
+
123
+ def test_sdk_lifecycle_and_user_agent(self) -> None:
124
+ admin = MassEngineClient(base_url=self.base_url, api_key=" admin-key ")
125
+ self.assertEqual(admin.health()["status"], "ok")
126
+ self.assertEqual(_MockGatewayHandler.last_user_agent, "massengine-python/1.1.3")
127
+ self.assertTrue(admin.ready()["ready"])
128
+ self.assertNotIn("admin-key", repr(admin))
129
+
130
+ q = admin.set_tenant_quota("acme", TenantQuota(plan="pro", max_docs=100, max_qps=500.0))
131
+ self.assertEqual(q["plan"], "pro")
132
+ self.assertEqual(q["max_docs"], 100)
133
+
134
+ t_key = admin.create_tenant_key("acme")
135
+ self.assertTrue(t_key.startswith("mk_"))
136
+
137
+ client = MassEngineClient(base_url=self.base_url, api_key=t_key)
138
+ ing = client.ingest(
139
+ "acme",
140
+ [
141
+ Doc(id="py-1", vector=[1.0, 0.0], category=2, metadata={"tier": "gold"}),
142
+ Doc(id="py-2", vector=[0.0, 1.0], category=3, metadata={"tier": "silver"}),
143
+ ],
144
+ )
145
+ self.assertEqual(ing["ingested"], 2)
146
+
147
+ sr = client.search(
148
+ "acme",
149
+ vector=[1.0, 0.0],
150
+ k=5,
151
+ metadata_filter={"tier": {"$eq": "gold"}},
152
+ )
153
+ self.assertEqual(len(sr["hits"]), 1)
154
+ self.assertEqual(sr["hits"][0]["id"], "py-1")
155
+
156
+ bs = client.batch_search(
157
+ [
158
+ SearchRequest(tenant="acme", vector=[1.0, 0.0], k=1),
159
+ SearchRequest(tenant="acme", vector=[0.0, 1.0], k=1),
160
+ ]
161
+ )
162
+ self.assertEqual(len(bs["results"]), 2)
163
+
164
+ patched = client.patch_doc("acme", "py-2", category=9, metadata={"tier": "gold"})
165
+ self.assertEqual(patched["category"], 9)
166
+
167
+ exported = client.export_tenant("acme")
168
+ self.assertEqual(len(exported), 2)
169
+
170
+ usage = client.get_tenant_usage("acme")
171
+ self.assertEqual(usage["plan"], "pro")
172
+ self.assertEqual(usage["live_docs"], 2)
173
+
174
+ revoked_client = MassEngineClient(base_url=self.base_url, api_key="revoked")
175
+ with self.assertRaises(MassEngineError) as ctx:
176
+ revoked_client.get_doc("acme", "py-1")
177
+ self.assertEqual(ctx.exception.status_code, 401)
178
+
179
+ def test_dot_segment_and_control_char_rejection(self) -> None:
180
+ client = MassEngineClient(base_url=self.base_url, api_key="mk_valid")
181
+ for bad_tenant in ["", ".", "..", "acme/evil", "acme\\evil", "acme\x00", "acme\n", "acme\x7f", 123]:
182
+ with self.assertRaises(ValueError):
183
+ client.get_tenant(bad_tenant) # type: ignore[arg-type]
184
+ with self.assertRaises(ValueError):
185
+ client.get_doc(bad_tenant, "doc-1") # type: ignore[arg-type]
186
+
187
+ for bad_doc_id in ["", ".", "..", "batch-get", "doc\x00", "doc\r\n", "doc\x7f", None]:
188
+ with self.assertRaises(ValueError):
189
+ client.get_doc("acme", bad_doc_id) # type: ignore[arg-type]
190
+ with self.assertRaises(ValueError):
191
+ client.delete_doc("acme", bad_doc_id) # type: ignore[arg-type]
192
+
193
+ def test_redirect_blocked_no_auth_leak(self) -> None:
194
+ _RedirectTrapHandler.contacted = False
195
+ _RedirectTrapHandler.leaked_auth = ""
196
+ client = MassEngineClient(base_url=self.base_url, api_key="mk_secret_token")
197
+ with self.assertRaises(MassEngineError) as ctx:
198
+ client.get_tenant("redirect-me")
199
+ self.assertEqual(ctx.exception.status_code, 302)
200
+ self.assertFalse(_RedirectTrapHandler.contacted)
201
+ self.assertEqual(_RedirectTrapHandler.leaked_auth, "")
202
+
203
+ def test_invalid_url_and_apikey_no_leak(self) -> None:
204
+ for bad_url in ["ftp://example.com", "file:///etc/passwd", "javascript:alert(1)", "http://u:p@example.com"]:
205
+ with self.assertRaises(ValueError):
206
+ MassEngineClient(base_url=bad_url)
207
+
208
+ secret = "mk_top_secret_value_999"
209
+ for bad_key in [
210
+ f"{secret}\r\nX-Injected: 1",
211
+ f"{secret}\n",
212
+ f"{secret}\x00",
213
+ f"{secret} space",
214
+ " ",
215
+ ]:
216
+ with self.assertRaises(ValueError) as ctx:
217
+ MassEngineClient(base_url=self.base_url, api_key=bad_key)
218
+ self.assertNotIn(secret, str(ctx.exception))
219
+
220
+ def test_timeout_and_max_response_bytes(self) -> None:
221
+ small_client = MassEngineClient(base_url=self.base_url, max_response_bytes=128)
222
+ with self.assertRaises(MassEngineError) as ctx:
223
+ small_client.get_tenant("oversized")
224
+ self.assertIn("max_response_bytes", str(ctx.exception))
225
+
226
+ tiny_export_client = MassEngineClient(base_url=self.base_url, max_response_bytes=20)
227
+ with self.assertRaises(MassEngineError) as ctx2:
228
+ tiny_export_client.export_tenant("acme")
229
+ self.assertIn("max_response_bytes", str(ctx2.exception))
230
+
231
+ fast_client = MassEngineClient(base_url=self.base_url, timeout=0.03)
232
+ with self.assertRaises((TimeoutError, socket.timeout, OSError)):
233
+ fast_client.flush()
234
+
235
+
236
+ if __name__ == "__main__":
237
+ unittest.main()