pacificdb 1.2.0__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.
pacificdb/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ from .client import PacificDBClient
2
+ from .connection import parse_connection_url
3
+ from .errors import PacificDBError, MediaUploadError
4
+
5
+ PacificDB = PacificDBClient
6
+ __all__ = ["PacificDB", "PacificDBClient", "PacificDBError", "MediaUploadError", "parse_connection_url"]
7
+
8
+ __version__ = '1.2.0'
9
+ release_info = {'version': '1.2.0', 'releaseVersion': '1.2.0', 'name': 'PacificDB 1.2.0', 'channel': 'stable', 'tag': 'v1.2.0'}
pacificdb/client.py ADDED
@@ -0,0 +1,334 @@
1
+ import json
2
+ import math
3
+ import threading
4
+ import time
5
+ from typing import Any
6
+ from .connection import parse_connection_url
7
+ from .errors import PacificDBError, sanitize
8
+ from .transport import ConnectionPool
9
+
10
+
11
+ class PacificDBClient:
12
+ __slots__ = ('_settings', '_database', '_token', '_credentials', '_secrets',
13
+ '_auth_state', '_lock', '_pool', '_closed')
14
+
15
+ def __init__(self, host: str = "127.0.0.1", port: int = 9000,
16
+ user_id: str = "system", database: str = "",
17
+ use_tls: bool = False, ca_file: str | None = None,
18
+ timeout: float = 30.0, *, pool_size: int = 16,
19
+ max_response_bytes: int = 64*1024*1024,
20
+ username: str | None = None, password: str | None = None):
21
+ if not isinstance(host, str) or not host or any(c.isspace() for c in host) or '/' in host or '@' in host:
22
+ raise PacificDBError('invalid_connection_options')
23
+ if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
24
+ raise PacificDBError('invalid_connection_options')
25
+ if isinstance(pool_size, bool) or not isinstance(pool_size, int) or not 1 <= pool_size <= 32:
26
+ raise PacificDBError('invalid_connection_options')
27
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or not 0 < timeout <= 1800:
28
+ raise PacificDBError('invalid_connection_options')
29
+ if isinstance(max_response_bytes, bool) or not isinstance(max_response_bytes, int) or not 1 <= max_response_bytes <= 64*1024*1024:
30
+ raise PacificDBError('invalid_connection_options')
31
+ if not isinstance(use_tls, bool) or ca_file and not use_tls:
32
+ raise PacificDBError('invalid_connection_options')
33
+ if not isinstance(user_id, str) or not user_id or not isinstance(database, str):
34
+ raise PacificDBError('invalid_connection_options')
35
+ if (username is not None or password is not None) and (not isinstance(username, str) or not username or not isinstance(password, str) or not password):
36
+ raise PacificDBError('invalid_connection_options')
37
+ self._settings = (host, port, user_id, use_tls, ca_file, timeout, pool_size)
38
+ self._lock = threading.Condition(threading.RLock())
39
+ self._database, self._token, self._closed = database, '', False
40
+ self._credentials = (username, password) if username is not None else None
41
+ self._secrets = {password} if password else set()
42
+ self._auth_state = 'new'
43
+ self._pool = ConnectionPool(host, port, timeout=timeout, pool_size=pool_size,
44
+ use_tls=use_tls, ca_file=ca_file, max_response_bytes=max_response_bytes)
45
+
46
+ host = property(lambda self: self._settings[0])
47
+ port = property(lambda self: self._settings[1])
48
+ user_id = property(lambda self: self._settings[2])
49
+ use_tls = property(lambda self: self._settings[3])
50
+ ca_file = property(lambda self: self._settings[4])
51
+ timeout = property(lambda self: self._settings[5])
52
+ pool_size = property(lambda self: self._settings[6])
53
+
54
+ @property
55
+ def database(self):
56
+ with self._lock: return self._database
57
+
58
+ @database.setter
59
+ def database(self, value):
60
+ if not isinstance(value, str): raise PacificDBError('invalid_database')
61
+ with self._lock: self._database = value
62
+
63
+ @property
64
+ def token(self):
65
+ with self._lock: return self._token
66
+
67
+ @token.setter
68
+ def token(self, value):
69
+ if not isinstance(value, str): raise PacificDBError('invalid_token')
70
+ with self._lock:
71
+ self._token = value
72
+ if value: self._secrets.add(value)
73
+
74
+ @classmethod
75
+ def from_url(cls, url, **options):
76
+ return cls(**parse_connection_url(url, options))
77
+
78
+ @classmethod
79
+ def connect(cls, url, **options):
80
+ client = cls.from_url(url, **options)
81
+ try:
82
+ client.request({'action': 'ping'})
83
+ return client
84
+ except BaseException:
85
+ client.close()
86
+ raise
87
+
88
+ def __repr__(self):
89
+ return f'PacificDBClient(host={self.host!r}, port={self.port}, database={self.database!r}, tls={self.use_tls})'
90
+
91
+ def __enter__(self):
92
+ with self._lock:
93
+ if self._closed: raise PacificDBError('client_closed')
94
+ return self
95
+
96
+ def __exit__(self, *_):
97
+ self.close()
98
+
99
+ def close(self):
100
+ with self._lock:
101
+ self._closed = True
102
+ self._lock.notify_all()
103
+ self._pool.close()
104
+
105
+ def _safe_error(self, error):
106
+ with self._lock: secrets = tuple(self._secrets)
107
+ if not isinstance(error, PacificDBError):
108
+ return PacificDBError('invalid_request', 'Invalid PacificDB request')
109
+ return PacificDBError(sanitize(error.code, secrets), sanitize(str(error), secrets), sanitize(error.response, secrets))
110
+
111
+ def _send(self, payload, deadline):
112
+ def learn(value):
113
+ if isinstance(value, dict):
114
+ for field, item in value.items():
115
+ if any(part in str(field).lower() for part in ('password', 'token', 'authorization')) and isinstance(item, str) and item:
116
+ self._secrets.add(item)
117
+ learn(item)
118
+ elif isinstance(value, list):
119
+ for item in value: learn(item)
120
+ with self._lock: learn(payload)
121
+ try: wire = json.dumps(payload, separators=(',', ':'), ensure_ascii=False, allow_nan=False).encode('utf-8')+b'\n'
122
+ except (TypeError, ValueError, UnicodeError): raise PacificDBError('invalid_request', 'Command must contain valid JSON values') from None
123
+ value = self._pool.request(wire, deadline=deadline)
124
+ if isinstance(value, dict) and value.get('error'):
125
+ error = value['error']
126
+ code = error.get('code', 'engine_error') if isinstance(error, dict) else str(error)
127
+ message = error.get('message', code) if isinstance(error, dict) else value.get('message', code)
128
+ raise PacificDBError(code, str(message), value)
129
+ return value
130
+
131
+ def _ensure_authenticated(self, deadline):
132
+ with self._lock:
133
+ if self._closed: raise PacificDBError('client_closed')
134
+ if not self._credentials: return self._token
135
+ while self._auth_state == 'pending' and not self._closed:
136
+ remaining = deadline-time.monotonic()
137
+ if remaining <= 0: raise PacificDBError('request_timeout')
138
+ self._lock.wait(remaining)
139
+ if self._closed or self._auth_state == 'failed': raise PacificDBError('client_closed')
140
+ if self._auth_state == 'ready': return self._token
141
+ self._auth_state = 'pending'
142
+ username, password = self._credentials
143
+ scope = {'userId': self.user_id, 'dbName': self._database}
144
+ try:
145
+ result = self._send({**scope, 'action': 'security_authenticate', 'username': username, 'password': password}, deadline)
146
+ if not isinstance(result, dict) or not isinstance(result.get('token'), str) or not result['token']:
147
+ raise PacificDBError('invalid_response', 'Invalid authentication response')
148
+ with self._lock:
149
+ if self._closed: raise PacificDBError('client_closed')
150
+ self.token = result['token']; self._auth_state = 'ready'; self._lock.notify_all()
151
+ return self._token
152
+ except BaseException:
153
+ with self._lock: self._auth_state = 'failed'
154
+ self.close()
155
+ raise
156
+
157
+ def _scoped_request(self):
158
+ with self._lock:
159
+ scope = {'userId': self.user_id, 'dbName': self._database}
160
+ if self._token: scope['token'] = self._token
161
+ def request(command):
162
+ deadline = time.monotonic()+self.timeout
163
+ try:
164
+ if not isinstance(command, dict): raise PacificDBError('invalid_request')
165
+ payload = json.loads(json.dumps({**scope, **command}, allow_nan=False))
166
+ if command.get('action') != 'security_authenticate':
167
+ token = self._ensure_authenticated(deadline)
168
+ if token:
169
+ scope.setdefault('token', token); payload.setdefault('token', scope['token'])
170
+ return self._send(payload, deadline)
171
+ except (PacificDBError, ValueError, TypeError, UnicodeError) as error:
172
+ failure = self._safe_error(error)
173
+ # Raise outside the handler so __context__ cannot retain a private engine error.
174
+ raise failure from None
175
+ request.wire_bytes = lambda command: len(json.dumps({**scope, **command}, separators=(',', ':'), ensure_ascii=False, allow_nan=False).encode('utf-8')) + 1
176
+ return request
177
+
178
+ def request(self, command: dict[str, Any]) -> Any:
179
+ return self._scoped_request()(command)
180
+
181
+ def authenticate(self, username: str, password: str) -> dict[str, Any]:
182
+ if not isinstance(username, str) or not username or not isinstance(password, str) or not password:
183
+ raise PacificDBError('invalid_credentials')
184
+ with self._lock: self._secrets.add(password)
185
+ result = self.request({'action': 'security_authenticate', 'username': username, 'password': password})
186
+ if not isinstance(result, dict) or not isinstance(result.get('token'), str) or not result['token']:
187
+ raise PacificDBError('invalid_response', 'Invalid authentication response')
188
+ self.token = result['token']
189
+ return result
190
+
191
+ @staticmethod
192
+ def _name(value, kind):
193
+ if not isinstance(value, str) or not value:
194
+ raise PacificDBError(f'invalid_{kind}', f'A non-empty {kind} name is required')
195
+ return value
196
+
197
+ @staticmethod
198
+ def _object(value):
199
+ if not isinstance(value, dict): raise PacificDBError('invalid_request', 'Expected a JSON object')
200
+ return value
201
+
202
+ @staticmethod
203
+ def _array(value):
204
+ if not isinstance(value, list): raise PacificDBError('invalid_request', 'Expected a JSON array')
205
+ return value
206
+
207
+ def _database_request(self, command):
208
+ with self._lock:
209
+ if not self._database: raise PacificDBError('database_required', 'database_required: select or create a database first')
210
+ request = self._scoped_request()
211
+ return request(command)
212
+
213
+ def _collection_request(self, collection, action, **fields):
214
+ self._name(collection, 'collection')
215
+ return self._database_request({'action': action, 'collection': collection, **fields})
216
+
217
+ @property
218
+ def indexes(self):
219
+ from .operations import Indexes
220
+ return Indexes(self)
221
+
222
+ @property
223
+ def vectors(self):
224
+ from .operations import Vectors
225
+ return Vectors(self)
226
+
227
+ @property
228
+ def media(self):
229
+ from .operations import Media
230
+ return Media(self)
231
+
232
+ @property
233
+ def backups(self):
234
+ from .operations import Backups
235
+ return Backups(self)
236
+
237
+ @property
238
+ def security(self):
239
+ from .operations import Security
240
+ return Security(self)
241
+
242
+ @property
243
+ def admin(self):
244
+ from .operations import Admin
245
+ return Admin(self)
246
+
247
+ def capabilities(self):
248
+ return self.request({'action': 'community_capabilities'})
249
+
250
+ def create_database(self, name: str | None = None, db_type: str = "binary") -> Any:
251
+ name = self._name(self.database if name is None else name, 'database')
252
+ self._name(db_type, 'database_type')
253
+ response = self.request({'action': 'createDatabase', 'dbName': name, 'dbType': db_type})
254
+ self.database = name
255
+ return response
256
+
257
+ def list_databases(self) -> list[str]:
258
+ response = self.request({'action': 'listDatabases'})
259
+ if not isinstance(response, list) or not all(isinstance(name, str) for name in response):
260
+ raise PacificDBError('unexpected_response_shape', 'Expected an array of database names')
261
+ return response
262
+
263
+ def use_database(self, name: str):
264
+ self._name(name, 'database')
265
+ names = self.list_databases()
266
+ if name not in names: raise PacificDBError('database_not_found')
267
+ self.database = name
268
+ return names
269
+
270
+ def drop_database(self, name: str | None = None):
271
+ with self._lock:
272
+ name = self._name(self._database if name is None else name, 'database')
273
+ request = self._scoped_request()
274
+ response = request({'action': 'dropDatabase', 'dbName': name})
275
+ with self._lock:
276
+ if self._database == name: self._database = ''
277
+ return response
278
+
279
+ def create_collection(self, name: str, *, vector_schema=None) -> Any:
280
+ return self._collection_request(name, 'createCollection',
281
+ **({'vector_schema': vector_schema} if vector_schema is not None else {}))
282
+
283
+ def list_collections(self):
284
+ return self._database_request({'action': 'listCollections'})
285
+
286
+ def drop_collection(self, name: str):
287
+ return self._collection_request(name, 'dropCollection')
288
+
289
+ def insert(self, collection: str, document: dict[str, Any]) -> Any:
290
+ return self._collection_request(collection, 'insert', data=self._object(document))
291
+
292
+ def insert_many(self, collection: str, documents: list[dict]):
293
+ self._array(documents)
294
+ for document in documents: self._object(document)
295
+ return self._collection_request(collection, 'insertMany', data=documents)
296
+
297
+ def find(self, collection: str, filter: dict[str, Any] | None = None,
298
+ limit: int = -1, offset: int = 0) -> Any:
299
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit < -1 or isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
300
+ raise PacificDBError('invalid_pagination', 'limit must be -1 or nonnegative; offset must be nonnegative')
301
+ return self._collection_request(collection, 'find', filter=self._object({} if filter is None else filter), limit=limit, offset=offset)
302
+
303
+ def find_one(self, collection: str, filter: dict | None = None):
304
+ response = self.find(collection, filter, limit=1)
305
+ rows = response.get('data') if isinstance(response, dict) else response
306
+ if not isinstance(rows, list) or rows and not isinstance(rows[0], dict):
307
+ raise PacificDBError('unexpected_response_shape', 'Expected an array of documents')
308
+ return rows[0] if rows else None
309
+
310
+ def count(self, collection: str, filter: dict | None = None):
311
+ return self._collection_request(collection, 'count', filter=self._object({} if filter is None else filter))
312
+
313
+ def aggregate(self, collection: str, pipeline: list):
314
+ return self._collection_request(collection, 'aggregate', pipeline=self._array(pipeline))
315
+
316
+ def explain(self, collection: str, filter: dict | None = None):
317
+ return self._collection_request(collection, 'explain', filter=self._object({} if filter is None else filter))
318
+
319
+ def update_one(self, collection: str, filter: dict[str, Any], update: dict[str, Any]) -> Any:
320
+ return self._collection_request(collection, 'updateOne', filter=self._object(filter), update=self._object(update))
321
+
322
+ def update_many(self, collection: str, filter: dict, update: dict):
323
+ return self._collection_request(collection, 'updateMany', filter=self._object(filter), update=self._object(update))
324
+
325
+ def delete_one(self, collection: str, filter: dict[str, Any]) -> Any:
326
+ return self._collection_request(collection, 'deleteOne', filter=self._object(filter))
327
+
328
+ def delete_many(self, collection: str, filter: dict):
329
+ return self._collection_request(collection, 'deleteMany', filter=self._object(filter))
330
+
331
+ def bulk_write(self, collection: str, operations: list):
332
+ self._array(operations)
333
+ for operation in operations: self._object(operation)
334
+ return self._collection_request(collection, 'bulkWrite', ops=operations)
@@ -0,0 +1,88 @@
1
+ """Parse the shared connection URL grammar without performing I/O."""
2
+ import ipaddress
3
+ import re
4
+ from urllib.parse import unquote_to_bytes
5
+ from .errors import PacificDBError
6
+
7
+
8
+ def _invalid():
9
+ return PacificDBError('invalid_connection_url', 'Invalid PacificDB connection URL or conflicting options')
10
+
11
+
12
+ def _decode(value):
13
+ if re.search(r'%(?![0-9a-fA-F]{2})', value):
14
+ raise _invalid()
15
+ try:
16
+ decoded = unquote_to_bytes(value).decode('utf-8', errors='strict')
17
+ if not decoded or any(ord(char) < 32 or ord(char) == 127 for char in decoded):
18
+ raise _invalid()
19
+ return decoded
20
+ except (UnicodeError, ValueError):
21
+ pass
22
+ raise _invalid() from None
23
+
24
+
25
+ def _integer(value, maximum):
26
+ if isinstance(value, bool) or not re.fullmatch('[0-9]+', str(value)):
27
+ raise _invalid()
28
+ try: number = int(value)
29
+ except ValueError: raise _invalid() from None
30
+ if not 1 <= number <= maximum:
31
+ raise _invalid()
32
+ return number
33
+
34
+
35
+ def parse_connection_url(url: str, options: dict | None = None) -> dict:
36
+ if not isinstance(url, str) or re.search(r'[\s\x00-\x1f\x7f#]', url):
37
+ raise _invalid()
38
+ match = re.fullmatch(r'(pacificdbs?)://([^/?#]+)(/[^?#]*)(?:\?([^#]*))?', url)
39
+ if not match:
40
+ raise _invalid()
41
+ scheme, authority, path, query = match.groups()
42
+ database = _decode(path[1:])
43
+ if '/' in database or '\\' in database or database in ('.', '..') or authority.count('@') > 1:
44
+ raise _invalid()
45
+ credentials = {}
46
+ address = authority
47
+ if '@' in authority:
48
+ userinfo, address = authority.split('@')
49
+ if ':' not in userinfo:
50
+ raise _invalid()
51
+ username, password = userinfo.split(':', 1)
52
+ credentials = {'username': _decode(username), 'password': _decode(password)}
53
+ if address.startswith('['):
54
+ host_match = re.fullmatch(r'\[([^\]]+)\](?::([0-9]+))?', address)
55
+ if not host_match: raise _invalid()
56
+ host, port = host_match.groups()
57
+ try: ipaddress.IPv6Address(host)
58
+ except ValueError: raise _invalid() from None
59
+ if '%' in host: raise _invalid()
60
+ else:
61
+ host_match = re.fullmatch(r'([A-Za-z0-9._-]+)(?::([0-9]+))?', address)
62
+ if not host_match: raise _invalid()
63
+ host, port = host_match.groups()
64
+ result = {'host': host.lower(), 'port': _integer(port, 65535) if port else 9000,
65
+ 'database': database, 'use_tls': scheme == 'pacificdbs',
66
+ 'user_id': 'system', 'timeout': 30.0, 'pool_size': 16, **credentials}
67
+ fixed = {'host', 'port', 'database', 'use_tls', *credentials}
68
+ names = {'userId': 'user_id', 'timeoutMs': 'timeout', 'poolSize': 'pool_size', 'caFile': 'ca_file'}
69
+ seen = set()
70
+ for pair in query.split('&') if query else []:
71
+ if '=' not in pair: raise _invalid()
72
+ key, value = [_decode(piece.replace('+', '%20')) for piece in pair.split('=', 1)]
73
+ if key not in names or key in seen: raise _invalid()
74
+ seen.add(key); fixed.add(names[key])
75
+ result[names[key]] = (_integer(value, 1800000) / 1000 if key == 'timeoutMs' else
76
+ _integer(value, 32) if key == 'poolSize' else value)
77
+ for key, value in (options or {}).items():
78
+ if key not in {*result, 'ca_file', 'max_response_bytes', 'username', 'password'}:
79
+ raise _invalid()
80
+ if key in fixed and result.get(key) != value:
81
+ raise _invalid()
82
+ result[key] = value
83
+ result['pool_size'] = _integer(result['pool_size'], 32)
84
+ timeout = result['timeout']
85
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 0.001 <= timeout <= 1800:
86
+ raise _invalid()
87
+ if result.get('ca_file') and not result['use_tls']: raise _invalid()
88
+ return result
pacificdb/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Public engine/transport errors; diagnostic values contain no known secrets."""
2
+ import re
3
+
4
+
5
+ def sanitize(value, secrets=()):
6
+ if isinstance(value, str):
7
+ value = re.sub(r'pacificdbs?://[^\s/]*@', 'pacificdb://[redacted]@', value)
8
+ for secret in secrets:
9
+ if secret:
10
+ value = value.replace(secret, '[redacted]')
11
+ return value
12
+ if isinstance(value, dict):
13
+ return {sanitize(key, secrets): '[redacted]' if re.search('password|token|authorization', str(key), re.I)
14
+ else sanitize(item, secrets) for key, item in value.items()}
15
+ if isinstance(value, list):
16
+ return [sanitize(item, secrets) for item in value]
17
+ return value
18
+
19
+
20
+ class PacificDBError(RuntimeError):
21
+ def __init__(self, code, message=None, response=None):
22
+ self.code = str(code)
23
+ self.response = response
24
+ super().__init__(message or self.code)
25
+
26
+
27
+ class MediaUploadError(PacificDBError):
28
+ def __init__(self, code, message=None, response=None, *, upload_id=None,
29
+ next_chunk=0, received_chunks=0, received_bytes=0, resumable=False):
30
+ super().__init__(code, message, response)
31
+ self.upload_id, self.next_chunk = upload_id, next_chunk
32
+ self.received_chunks, self.received_bytes = received_chunks, received_bytes
33
+ self.resumable = resumable