nerqon 1.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.
nerqon-1.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NidhiTek
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.
@@ -0,0 +1,65 @@
1
+ """
2
+ Nerqon — Official Python SDK
3
+ ==================================
4
+
5
+ Hybrid Vector + Graph Database for AI applications.
6
+
7
+ Quick Start::
8
+
9
+ from Nerqon import Nerqon
10
+
11
+ client = Nerqon(api_key="nidx_your_key")
12
+
13
+ # Add a document
14
+ client.add("doc-1", vector=[0.1, 0.2, ...], text="Hello world")
15
+
16
+ # Search
17
+ results = client.search(vector=[0.1, 0.2, ...], top_k=5)
18
+ for r in results.results:
19
+ print(f"{r.id}: {r.score:.4f}")
20
+
21
+ Full docs: https://nidhitek.com/documentation.html
22
+ """
23
+
24
+ __version__ = "1.1.0"
25
+
26
+ from .client import Nerqon
27
+ from .exceptions import (
28
+ AuthenticationError,
29
+ ConnectionError,
30
+ DimensionMismatchError,
31
+ NerqonError,
32
+ NotFoundError,
33
+ RateLimitError,
34
+ ServerError,
35
+ ValidationError,
36
+ )
37
+ from .models import (
38
+ Document,
39
+ HealthStatus,
40
+ Namespace,
41
+ SearchResponse,
42
+ SearchResult,
43
+ Stats,
44
+ )
45
+
46
+ __all__ = [
47
+ # Client
48
+ "Nerqon",
49
+ # Exceptions
50
+ "NerqonError",
51
+ "AuthenticationError",
52
+ "RateLimitError",
53
+ "NotFoundError",
54
+ "ValidationError",
55
+ "DimensionMismatchError",
56
+ "ServerError",
57
+ "ConnectionError",
58
+ # Models
59
+ "Document",
60
+ "SearchResult",
61
+ "SearchResponse",
62
+ "Namespace",
63
+ "Stats",
64
+ "HealthStatus",
65
+ ]
@@ -0,0 +1,453 @@
1
+ """
2
+ Nerqon SDK — Python Client
3
+ ================================
4
+
5
+ Official Python client for the Nerqon Hybrid Vector + Graph Database API.
6
+
7
+ Usage:
8
+ from Nerqon import Nerqon
9
+
10
+ client = Nerqon(api_key="nidx_your_key")
11
+ client.add("doc-1", text="Hello world", vector=[0.1, 0.2, ...])
12
+ results = client.search(vector=[0.1, 0.2, ...], top_k=5)
13
+ """
14
+
15
+ import time
16
+ from typing import Any, Dict, List, Optional, Union
17
+
18
+ import requests
19
+
20
+ from .exceptions import (
21
+ AuthenticationError,
22
+ ConnectionError,
23
+ DimensionMismatchError,
24
+ NerqonError,
25
+ NotFoundError,
26
+ RateLimitError,
27
+ ServerError,
28
+ ValidationError,
29
+ )
30
+ from .models import (
31
+ Document,
32
+ HealthStatus,
33
+ Namespace,
34
+ SearchResponse,
35
+ Stats,
36
+ )
37
+
38
+ __all__ = ["Nerqon"]
39
+
40
+ _DEFAULT_BASE_URL = "https://api.nidhitek.com"
41
+ _DEFAULT_TIMEOUT = 30
42
+ _MAX_RETRIES = 3
43
+
44
+
45
+ class Nerqon:
46
+ """
47
+ Official Python client for the Nerqon API.
48
+
49
+ Args:
50
+ api_key: Your Nerqon API key (starts with ``nidx_``).
51
+ base_url: API base URL. Defaults to ``https://api.nidhitek.com``.
52
+ timeout: Request timeout in seconds. Defaults to 30.
53
+ max_retries: Max retries on transient failures. Defaults to 3.
54
+ namespace: Default namespace for all operations.
55
+
56
+ Example::
57
+
58
+ from Nerqon import Nerqon
59
+
60
+ client = Nerqon(api_key="nidx_your_key")
61
+
62
+ # Add a document
63
+ client.add("doc-1", text="AI is transforming search", vector=[0.1, 0.2, ...])
64
+
65
+ # Search
66
+ results = client.search(vector=[0.1, 0.2, ...], top_k=5)
67
+ for r in results.results:
68
+ print(f"{r.id}: {r.score:.4f} — {r.text[:80]}")
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ api_key: str,
74
+ base_url: str = _DEFAULT_BASE_URL,
75
+ timeout: int = _DEFAULT_TIMEOUT,
76
+ max_retries: int = _MAX_RETRIES,
77
+ namespace: str = "default",
78
+ ):
79
+ if not api_key:
80
+ raise AuthenticationError("api_key is required. Get one at https://nidhitek.com/dashboard.html")
81
+
82
+ self.api_key = api_key
83
+ self.base_url = base_url.rstrip("/")
84
+ self.timeout = timeout
85
+ self.max_retries = max_retries
86
+ self.namespace = namespace
87
+
88
+ self._session = requests.Session()
89
+ self._session.headers.update({
90
+ "Authorization": f"Bearer {api_key}",
91
+ "Content-Type": "application/json",
92
+ "User-Agent": "Nerqon-python/1.1.0",
93
+ })
94
+
95
+ # ──────────────────────────────────────────────
96
+ # Internal HTTP helpers
97
+ # ──────────────────────────────────────────────
98
+
99
+ def _url(self, path: str) -> str:
100
+ return f"{self.base_url}{path}"
101
+
102
+ def _request(self, method: str, path: str, **kwargs) -> dict:
103
+ """Make an HTTP request with retries and error handling."""
104
+ kwargs.setdefault("timeout", self.timeout)
105
+ last_error = None
106
+
107
+ for attempt in range(self.max_retries):
108
+ try:
109
+ resp = self._session.request(method, self._url(path), **kwargs)
110
+ return self._handle_response(resp)
111
+ except (requests.ConnectionError, requests.Timeout) as e:
112
+ last_error = ConnectionError(
113
+ f"Cannot connect to Nerqon at {self.base_url}: {e}"
114
+ )
115
+ if attempt < self.max_retries - 1:
116
+ time.sleep(min(2 ** attempt, 8))
117
+ continue
118
+ except NerqonError:
119
+ raise
120
+ except Exception as e:
121
+ raise NerqonError(f"Unexpected error: {e}")
122
+
123
+ raise last_error
124
+
125
+ def _handle_response(self, resp: requests.Response) -> dict:
126
+ """Parse response and raise appropriate exceptions."""
127
+ if resp.status_code == 204:
128
+ return {}
129
+
130
+ try:
131
+ data = resp.json()
132
+ except ValueError:
133
+ data = {"detail": resp.text}
134
+
135
+ if 200 <= resp.status_code < 300:
136
+ return data
137
+
138
+ detail = data.get("detail", data.get("message", str(data)))
139
+
140
+ if resp.status_code in (401, 403):
141
+ raise AuthenticationError(detail, status_code=resp.status_code, response=data)
142
+ elif resp.status_code == 404:
143
+ raise NotFoundError(detail, status_code=404, response=data)
144
+ elif resp.status_code == 422:
145
+ if "dimension" in str(detail).lower():
146
+ raise DimensionMismatchError(detail, status_code=422, response=data)
147
+ raise ValidationError(detail, status_code=422, response=data)
148
+ elif resp.status_code == 429:
149
+ retry_after = int(resp.headers.get("Retry-After", 60))
150
+ raise RateLimitError(
151
+ detail, retry_after=retry_after, status_code=429, response=data
152
+ )
153
+ elif resp.status_code >= 500:
154
+ raise ServerError(detail, status_code=resp.status_code, response=data)
155
+ else:
156
+ raise NerqonError(detail, status_code=resp.status_code, response=data)
157
+
158
+ def _ns(self, namespace: Optional[str]) -> str:
159
+ """Resolve namespace — use explicit value or fall back to default."""
160
+ return namespace or self.namespace
161
+
162
+ # ──────────────────────────────────────────────
163
+ # Health & Stats
164
+ # ──────────────────────────────────────────────
165
+
166
+ def health(self) -> HealthStatus:
167
+ """Check API health. Does not require authentication.
168
+
169
+ Returns:
170
+ HealthStatus with server status, version, and uptime.
171
+ """
172
+ resp = self._session.get(self._url("/health"), timeout=self.timeout)
173
+ data = resp.json() if resp.status_code == 200 else {}
174
+ return HealthStatus.from_dict(data)
175
+
176
+ def stats(self, namespace: str = None) -> Stats:
177
+ """Get index statistics for a namespace.
178
+
179
+ Args:
180
+ namespace: Target namespace. Uses default if not specified.
181
+
182
+ Returns:
183
+ Stats object with document counts, dimension, cache hit rate, etc.
184
+ """
185
+ data = self._request("GET", "/v1/stats", params={"namespace": self._ns(namespace)})
186
+ return Stats.from_dict(data)
187
+
188
+ # ──────────────────────────────────────────────
189
+ # Documents
190
+ # ──────────────────────────────────────────────
191
+
192
+ def add(
193
+ self,
194
+ id: str,
195
+ vector: List[float],
196
+ text: str = "",
197
+ metadata: Dict[str, Any] = None,
198
+ namespace: str = None,
199
+ ) -> Document:
200
+ """Add a single document.
201
+
202
+ Args:
203
+ id: Unique document identifier.
204
+ vector: Embedding vector (list of floats).
205
+ text: Document text content.
206
+ metadata: Optional key-value metadata.
207
+ namespace: Target namespace. Uses default if not specified.
208
+
209
+ Returns:
210
+ Document object with the stored document details.
211
+
212
+ Raises:
213
+ DimensionMismatchError: If vector dimension doesn't match namespace config.
214
+ ValidationError: If parameters are invalid.
215
+ """
216
+ payload = {
217
+ "id": id,
218
+ "vector": vector,
219
+ "text": text,
220
+ "metadata": metadata or {},
221
+ "namespace": self._ns(namespace),
222
+ }
223
+ data = self._request("POST", "/v1/documents", json=payload)
224
+ return Document.from_dict(data) if isinstance(data, dict) else Document(id=id)
225
+
226
+ def add_batch(
227
+ self,
228
+ documents: List[Dict[str, Any]],
229
+ namespace: str = None,
230
+ ) -> dict:
231
+ """Add multiple documents in a single request.
232
+
233
+ Args:
234
+ documents: List of document dicts, each with keys:
235
+ ``id``, ``vector``, ``text`` (optional), ``metadata`` (optional).
236
+ namespace: Target namespace for all documents.
237
+
238
+ Returns:
239
+ dict with batch operation results (e.g. ``{"added": 10}``).
240
+
241
+ Example::
242
+
243
+ docs = [
244
+ {"id": "d1", "vector": [0.1, ...], "text": "First doc"},
245
+ {"id": "d2", "vector": [0.2, ...], "text": "Second doc"},
246
+ ]
247
+ result = client.add_batch(docs)
248
+ """
249
+ payload = {
250
+ "documents": documents,
251
+ "namespace": self._ns(namespace),
252
+ }
253
+ return self._request("POST", "/v1/documents/batch", json=payload)
254
+
255
+ def get(self, id: str, namespace: str = None) -> Document:
256
+ """Retrieve a document by ID.
257
+
258
+ Args:
259
+ id: Document identifier.
260
+ namespace: Target namespace.
261
+
262
+ Returns:
263
+ Document object.
264
+
265
+ Raises:
266
+ NotFoundError: If document doesn't exist.
267
+ """
268
+ data = self._request(
269
+ "GET", f"/documents/{id}",
270
+ params={"namespace": self._ns(namespace)},
271
+ )
272
+ return Document.from_dict(data)
273
+
274
+ def update(
275
+ self,
276
+ id: str,
277
+ text: str = None,
278
+ vector: List[float] = None,
279
+ metadata: Dict[str, Any] = None,
280
+ namespace: str = None,
281
+ ) -> Document:
282
+ """Update an existing document.
283
+
284
+ Args:
285
+ id: Document identifier.
286
+ text: New text content (optional).
287
+ vector: New embedding vector (optional).
288
+ metadata: New metadata (optional).
289
+ namespace: Target namespace.
290
+
291
+ Returns:
292
+ Updated Document object.
293
+ """
294
+ payload = {"namespace": self._ns(namespace)}
295
+ if text is not None:
296
+ payload["text"] = text
297
+ if vector is not None:
298
+ payload["vector"] = vector
299
+ if metadata is not None:
300
+ payload["metadata"] = metadata
301
+
302
+ data = self._request("PATCH", f"/documents/{id}", json=payload)
303
+ return Document.from_dict(data) if isinstance(data, dict) else Document(id=id)
304
+
305
+ def delete(self, id: str, namespace: str = None) -> dict:
306
+ """Delete a document by ID.
307
+
308
+ Args:
309
+ id: Document identifier.
310
+ namespace: Target namespace.
311
+
312
+ Returns:
313
+ dict with deletion confirmation.
314
+
315
+ Raises:
316
+ NotFoundError: If document doesn't exist.
317
+ """
318
+ return self._request(
319
+ "DELETE", f"/documents/{id}",
320
+ params={"namespace": self._ns(namespace)},
321
+ )
322
+
323
+ # ──────────────────────────────────────────────
324
+ # Search
325
+ # ──────────────────────────────────────────────
326
+
327
+ def search(
328
+ self,
329
+ vector: List[float] = None,
330
+ text: str = None,
331
+ top_k: int = 10,
332
+ filters: Dict[str, Any] = None,
333
+ namespace: str = None,
334
+ mode: str = None,
335
+ use_graph: bool = None,
336
+ use_cache: bool = None,
337
+ ) -> SearchResponse:
338
+ """Search for similar documents.
339
+
340
+ Supports vector search, text search, and hybrid search modes.
341
+
342
+ Args:
343
+ vector: Query embedding vector.
344
+ text: Query text (if server-side embeddings are enabled).
345
+ top_k: Number of results to return (default: 10).
346
+ filters: Metadata filters using Query DSL.
347
+ namespace: Target namespace.
348
+ mode: Search mode — ``"vector"``, ``"hybrid"``, ``"graph"``.
349
+ use_graph: Enable graph traversal for richer results.
350
+ use_cache: Enable result caching.
351
+
352
+ Returns:
353
+ SearchResponse with results, scores, and query time.
354
+
355
+ Example::
356
+
357
+ # Vector search
358
+ results = client.search(vector=[0.1, 0.2, ...], top_k=5)
359
+
360
+ # With metadata filters
361
+ results = client.search(
362
+ vector=[0.1, ...],
363
+ filters={"category": "science", "year": {"$gte": 2024}},
364
+ )
365
+ """
366
+ payload = {
367
+ "top_k": top_k,
368
+ "namespace": self._ns(namespace),
369
+ }
370
+ if vector is not None:
371
+ payload["vector"] = vector
372
+ if text is not None:
373
+ payload["text"] = text
374
+ if filters:
375
+ payload["filters"] = filters
376
+ if mode:
377
+ payload["mode"] = mode
378
+ if use_graph is not None:
379
+ payload["use_graph"] = use_graph
380
+ if use_cache is not None:
381
+ payload["use_cache"] = use_cache
382
+
383
+ data = self._request("POST", "/v1/search", json=payload)
384
+ return SearchResponse.from_dict(data)
385
+
386
+ # ──────────────────────────────────────────────
387
+ # Namespaces
388
+ # ──────────────────────────────────────────────
389
+
390
+ def list_namespaces(self) -> List[Namespace]:
391
+ """List all namespaces.
392
+
393
+ Returns:
394
+ List of Namespace objects.
395
+ """
396
+ data = self._request("GET", "/namespaces")
397
+ namespaces = data if isinstance(data, list) else data.get("namespaces", [])
398
+ return [Namespace.from_dict(ns) if isinstance(ns, dict) else Namespace(name=str(ns)) for ns in namespaces]
399
+
400
+ def create_namespace(self, name: str, dimension: int = None) -> dict:
401
+ """Create a new namespace.
402
+
403
+ Args:
404
+ name: Namespace name (alphanumeric + hyphens).
405
+ dimension: Embedding dimension for this namespace (optional).
406
+
407
+ Returns:
408
+ dict with creation confirmation.
409
+ """
410
+ payload = {"name": name}
411
+ if dimension:
412
+ payload["dimension"] = dimension
413
+ return self._request("POST", "/namespaces", json=payload)
414
+
415
+ def delete_namespace(self, name: str) -> dict:
416
+ """Delete a namespace and all its data. This is irreversible.
417
+
418
+ Args:
419
+ name: Namespace to delete.
420
+
421
+ Returns:
422
+ dict with deletion confirmation.
423
+ """
424
+ return self._request("DELETE", f"/namespaces/{name}")
425
+
426
+ # ──────────────────────────────────────────────
427
+ # User Info
428
+ # ──────────────────────────────────────────────
429
+
430
+ def me(self) -> dict:
431
+ """Get current user information and usage stats.
432
+
433
+ Returns:
434
+ dict with user profile, plan, and usage data.
435
+ """
436
+ return self._request("GET", "/v1/me")
437
+
438
+ # ──────────────────────────────────────────────
439
+ # Context Manager
440
+ # ──────────────────────────────────────────────
441
+
442
+ def close(self):
443
+ """Close the HTTP session."""
444
+ self._session.close()
445
+
446
+ def __enter__(self):
447
+ return self
448
+
449
+ def __exit__(self, *args):
450
+ self.close()
451
+
452
+ def __repr__(self):
453
+ return f"Nerqon(base_url={self.base_url!r}, namespace={self.namespace!r})"
@@ -0,0 +1,58 @@
1
+ """
2
+ Nerqon SDK — Exception classes
3
+ ===================================
4
+
5
+ These exceptions are raised by the client when API calls fail.
6
+ They contain structured error information from the Nerqon API.
7
+ """
8
+
9
+
10
+ class NerqonError(Exception):
11
+ """Base exception for all Nerqon SDK errors."""
12
+
13
+ def __init__(self, message: str, status_code: int = None, response: dict = None):
14
+ self.message = message
15
+ self.status_code = status_code
16
+ self.response = response or {}
17
+ super().__init__(self.message)
18
+
19
+ def __repr__(self):
20
+ return f"{self.__class__.__name__}(message={self.message!r}, status_code={self.status_code})"
21
+
22
+
23
+ class AuthenticationError(NerqonError):
24
+ """Raised when API key is invalid or missing (HTTP 401/403)."""
25
+ pass
26
+
27
+
28
+ class RateLimitError(NerqonError):
29
+ """Raised when rate limit is exceeded (HTTP 429)."""
30
+
31
+ def __init__(self, message: str, retry_after: int = None, **kwargs):
32
+ super().__init__(message, **kwargs)
33
+ self.retry_after = retry_after
34
+
35
+
36
+ class NotFoundError(NerqonError):
37
+ """Raised when a resource is not found (HTTP 404)."""
38
+ pass
39
+
40
+
41
+ class ValidationError(NerqonError):
42
+ """Raised when request parameters are invalid (HTTP 422)."""
43
+ pass
44
+
45
+
46
+ class DimensionMismatchError(ValidationError):
47
+ """Raised when embedding dimensions don't match the namespace configuration."""
48
+ pass
49
+
50
+
51
+ class ServerError(NerqonError):
52
+ """Raised when the Nerqon server returns a 5xx error."""
53
+ pass
54
+
55
+
56
+ class ConnectionError(NerqonError):
57
+ """Raised when the SDK cannot connect to the Nerqon API."""
58
+ pass