camusdb-py 0.1.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.
camusdb/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """CamusDB driver for Python: PEP 249 (DB-API 2.0), with an asyncio interface in :mod:`camusdb.aio`.
2
+
3
+ ::
4
+
5
+ import camusdb
6
+
7
+ with camusdb.connect("Endpoint=http://localhost:5095;Database=test") as conn:
8
+ cur = conn.execute("SELECT name, year FROM robots WHERE year > @year", {"year": 1980})
9
+ for name, year in cur:
10
+ print(name, year)
11
+ """
12
+
13
+ from . import codes, rows
14
+ from ._config import redact as redact_connection_string
15
+ from ._protocol import Branch
16
+ from .connection import Connection, Cursor, connect, run_transaction
17
+ from .errors import (
18
+ DatabaseError,
19
+ DataError,
20
+ Error,
21
+ IntegrityError,
22
+ InterfaceError,
23
+ InternalError,
24
+ NotSupportedError,
25
+ OperationalError,
26
+ ProgrammingError,
27
+ SerializationFailure,
28
+ Warning,
29
+ )
30
+ from .retry import is_retryable
31
+ from .types import (
32
+ BINARY,
33
+ DATETIME,
34
+ NUMBER,
35
+ ROWID,
36
+ STRING,
37
+ Binary,
38
+ ColumnType,
39
+ Date,
40
+ DateFromTicks,
41
+ IsolationLevel,
42
+ Locking,
43
+ ObjectId,
44
+ Time,
45
+ TimeFromTicks,
46
+ Timestamp,
47
+ TimestampFromTicks,
48
+ Typed,
49
+ )
50
+
51
+ __version__ = "0.1.0"
52
+
53
+ # PEP 249 module globals.
54
+ apilevel = "2.0"
55
+ # Threads may share the module, but not connections.
56
+ threadsafety = 1
57
+ # Placeholders are named, and they are written with "@", as the server reads them: WHERE id = @id.
58
+ paramstyle = "named"
59
+
60
+ __all__ = [
61
+ "BINARY",
62
+ "DATETIME",
63
+ "NUMBER",
64
+ "ROWID",
65
+ "STRING",
66
+ "Binary",
67
+ "Branch",
68
+ "ColumnType",
69
+ "Connection",
70
+ "Cursor",
71
+ "DataError",
72
+ "DatabaseError",
73
+ "Date",
74
+ "DateFromTicks",
75
+ "Error",
76
+ "IntegrityError",
77
+ "InterfaceError",
78
+ "InternalError",
79
+ "IsolationLevel",
80
+ "Locking",
81
+ "NotSupportedError",
82
+ "ObjectId",
83
+ "OperationalError",
84
+ "ProgrammingError",
85
+ "SerializationFailure",
86
+ "Time",
87
+ "TimeFromTicks",
88
+ "Timestamp",
89
+ "TimestampFromTicks",
90
+ "Typed",
91
+ "Warning",
92
+ "apilevel",
93
+ "codes",
94
+ "connect",
95
+ "is_retryable",
96
+ "paramstyle",
97
+ "redact_connection_string",
98
+ "rows",
99
+ "run_transaction",
100
+ "threadsafety",
101
+ ]
camusdb/_auth.py ADDED
@@ -0,0 +1,122 @@
1
+ """Bearer tokens: one login for each set of credentials, shared by the whole process.
2
+
3
+ The server verifies a password slowly on purpose (PBKDF2) and limits logins to 20 for each account
4
+ each minute. Thus the token is cached for each (deployment, user, password), and every connection
5
+ with the same credentials uses it. A token is renewed when it reaches 80% of its lifetime. A token
6
+ that reaches 95% is never sent, because the server can expire it while the request travels.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import threading
13
+ import time
14
+ from dataclasses import dataclass
15
+
16
+ from ._config import Config
17
+
18
+ RENEW_AT = 0.80
19
+ EXPIRE_AT = 0.95
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Token:
24
+ value: str
25
+ issued: float # time.monotonic() when the login reply arrived
26
+ lifetime: float # seconds
27
+
28
+ def age_fraction(self) -> float:
29
+ return (time.monotonic() - self.issued) / self.lifetime
30
+
31
+ @property
32
+ def fresh(self) -> bool:
33
+ """The token is young enough that it needs no renewal."""
34
+ return self.age_fraction() < RENEW_AT
35
+
36
+ @property
37
+ def usable(self) -> bool:
38
+ """The token can still be sent, while a renewal is not available."""
39
+ return self.age_fraction() < EXPIRE_AT
40
+
41
+
42
+ class _TokenStore:
43
+ def __init__(self) -> None:
44
+ self._tokens: dict[tuple[object, ...], Token] = {}
45
+ self._login_locks: dict[tuple[object, ...], threading.Lock] = {}
46
+ self._lock = threading.Lock()
47
+
48
+ def get(self, key: tuple[object, ...]) -> Token | None:
49
+ with self._lock:
50
+ return self._tokens.get(key)
51
+
52
+ def put(self, key: tuple[object, ...], token: Token) -> None:
53
+ with self._lock:
54
+ self._tokens[key] = token
55
+
56
+ def discard(self, key: tuple[object, ...], value: str | None = None) -> None:
57
+ """Forget the token, but only if it is still ``value``: a newer token stays."""
58
+ with self._lock:
59
+ current = self._tokens.get(key)
60
+ if current is not None and (value is None or current.value == value):
61
+ del self._tokens[key]
62
+
63
+ def login_lock(self, key: tuple[object, ...]) -> threading.Lock:
64
+ with self._lock:
65
+ return self._login_locks.setdefault(key, threading.Lock())
66
+
67
+
68
+ TOKENS = _TokenStore()
69
+
70
+
71
+ class AuthState:
72
+ """The credentials of one connection, and access to the shared token for them."""
73
+
74
+ def __init__(self, config: Config) -> None:
75
+ self._endpoints = config.endpoints
76
+ self.default_lifetime = config.token_lifetime
77
+ self.access_token = config.access_token
78
+ self.user: str | None = None
79
+ self._password: str | None = None
80
+ self._key: tuple[object, ...] = ()
81
+ self.set_credentials(config.user, config.password)
82
+
83
+ def set_credentials(self, user: str | None, password: str | None) -> None:
84
+ self.user = user
85
+ self._password = password
86
+ digest = hashlib.sha256(password.encode()).hexdigest() if password is not None else None
87
+ self._key = (self._endpoints, user, digest)
88
+
89
+ @property
90
+ def can_login(self) -> bool:
91
+ return self.user is not None and self._password is not None
92
+
93
+ @property
94
+ def password(self) -> str:
95
+ assert self._password is not None
96
+ return self._password
97
+
98
+ @property
99
+ def enabled(self) -> bool:
100
+ return self.access_token is not None or self.can_login
101
+
102
+ def cached(self) -> Token | None:
103
+ return TOKENS.get(self._key) if self.can_login else None
104
+
105
+ def store(self, value: str, expires_in: float | None) -> Token:
106
+ lifetime = expires_in if expires_in and expires_in > 0 else self.default_lifetime
107
+ token = Token(value, time.monotonic(), lifetime)
108
+ TOKENS.put(self._key, token)
109
+ return token
110
+
111
+ def discard(self, value: str | None = None) -> None:
112
+ TOKENS.discard(self._key, value)
113
+
114
+ def login_lock(self) -> threading.Lock:
115
+ return TOKENS.login_lock(self._key)
116
+
117
+ @property
118
+ def current_token(self) -> str | None:
119
+ if self.access_token is not None:
120
+ return self.access_token
121
+ token = self.cached()
122
+ return token.value if token is not None and token.usable else None
camusdb/_base.py ADDED
@@ -0,0 +1,300 @@
1
+ """State and rules that the sync and the async connection and cursor share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections.abc import Iterator, Mapping
7
+ from typing import Any
8
+
9
+ from . import _protocol as proto
10
+ from . import _sql, codes
11
+ from ._codec import encode_parameters
12
+ from ._config import Config, coerce_isolation, coerce_locking
13
+ from .errors import InterfaceError, InternalError, ProgrammingError
14
+ from .rows import RowFactory, RowMaker, tuple_row
15
+ from .types import IsolationLevel, Locking
16
+
17
+ # A COMMIT or ROLLBACK whose outcome is not resolved yet (CADB0509) is sent again, the same one on the
18
+ # same transaction, never replayed from the start: the commit may already have applied.
19
+ MAX_FINALIZE_ATTEMPTS = 10
20
+
21
+
22
+ def finalize_delay(attempt: int) -> float:
23
+ """50 ms x 2^attempt, at most 3.2 s: the server's contract for an unresolved finalize."""
24
+ return 0.05 * (1 << min(attempt, 6))
25
+
26
+
27
+ def retry_delay(attempt: int) -> float:
28
+ """min(20 ms x 2^attempt, 400 ms), with 25% jitter: the back-off between transaction replays."""
29
+ base = min(0.02 * (1 << attempt), 0.4)
30
+ return max(0.001, base + base * 0.25 * (2 * random.random() - 1))
31
+
32
+
33
+ def aborted_error() -> InternalError:
34
+ return InternalError(
35
+ "the current transaction failed and the server discarded it; call rollback() before you run "
36
+ "another statement",
37
+ codes.UNKNOWN,
38
+ )
39
+
40
+
41
+ class ConnectionState:
42
+ """Transaction state and settings of one connection."""
43
+
44
+ def __init__(self, config: Config, row_factory: RowFactory | None) -> None:
45
+ self.config = config
46
+ self.database = config.database
47
+ self.row_factory: RowFactory = row_factory or tuple_row
48
+ self.closed = False
49
+ self._autocommit = config.autocommit
50
+ self._isolation_level = config.isolation_level
51
+ self._read_only = config.read_only
52
+ self._locking = config.locking
53
+ self.txn: proto.TransactionHandle | None = None
54
+ # True after a statement of the transaction failed. The server discarded the transaction.
55
+ self.txn_failed = False
56
+
57
+ # --- Settings ---------------------------------------------------------------------------------
58
+
59
+ def _check_settable(self, name: str) -> None:
60
+ if self.txn is not None:
61
+ raise ProgrammingError(
62
+ f"cannot change {name} while a transaction is active", codes.INVALID_INPUT
63
+ )
64
+
65
+ @property
66
+ def autocommit(self) -> bool:
67
+ return self._autocommit
68
+
69
+ @autocommit.setter
70
+ def autocommit(self, value: bool) -> None:
71
+ self._check_settable("autocommit")
72
+ self._autocommit = bool(value)
73
+
74
+ @property
75
+ def isolation_level(self) -> IsolationLevel | None:
76
+ return self._isolation_level
77
+
78
+ @isolation_level.setter
79
+ def isolation_level(self, value: IsolationLevel | str | None) -> None:
80
+ self._check_settable("isolation_level")
81
+ self._isolation_level = coerce_isolation(value)
82
+
83
+ @property
84
+ def read_only(self) -> bool | None:
85
+ return self._read_only
86
+
87
+ @read_only.setter
88
+ def read_only(self, value: bool | None) -> None:
89
+ self._check_settable("read_only")
90
+ self._read_only = None if value is None else bool(value)
91
+
92
+ @property
93
+ def locking(self) -> Locking | None:
94
+ return self._locking
95
+
96
+ @locking.setter
97
+ def locking(self, value: Locking | str | None) -> None:
98
+ self._check_settable("locking")
99
+ self._locking = coerce_locking(value)
100
+
101
+ def set_database(self, name: str) -> None:
102
+ self._check_settable("the database")
103
+ if not name:
104
+ raise ProgrammingError("a database name is required", codes.INVALID_INPUT)
105
+ self.database = name
106
+
107
+ def options(
108
+ self,
109
+ isolation_level: IsolationLevel | str | None = None,
110
+ read_only: bool | None = None,
111
+ locking: Locking | str | None = None,
112
+ ) -> proto.TransactionOptions:
113
+ """The options of a transaction: each one given here, else the connection's setting."""
114
+ return proto.TransactionOptions(
115
+ isolation_level=coerce_isolation(isolation_level) or self._isolation_level,
116
+ read_only=self._read_only if read_only is None else read_only,
117
+ locking=coerce_locking(locking) or self._locking,
118
+ )
119
+
120
+ # --- Rules ------------------------------------------------------------------------------------
121
+
122
+ def check_open(self) -> None:
123
+ if self.closed:
124
+ raise InterfaceError("the connection is closed")
125
+
126
+ def check_usable(self) -> None:
127
+ self.check_open()
128
+ if self.txn_failed:
129
+ raise aborted_error()
130
+
131
+ def needs_begin(self) -> bool:
132
+ """Whether a statement must begin the implicit transaction first (PEP 249 mode)."""
133
+ return self.txn is None and not self._autocommit
134
+
135
+ def statement_failed(self) -> None:
136
+ """Record that a statement sent in the transaction failed.
137
+
138
+ The server discards a transaction when one of its statements fails. Thus a later statement
139
+ must not run as if the transaction were still there. A failure before the request was sent,
140
+ such as a value that cannot be bound, does not come here.
141
+ """
142
+ if self.txn is not None:
143
+ self.txn_failed = True
144
+
145
+ def end_transaction(self) -> None:
146
+ self.txn = None
147
+ self.txn_failed = False
148
+
149
+
150
+ class Plan:
151
+ """What one ``execute`` sends: the route, the body builder input and the target."""
152
+
153
+ __slots__ = ("kind", "parameters", "sql")
154
+
155
+ def __init__(self, sql: str, parameters: Mapping[str, Any] | None) -> None:
156
+ if not isinstance(sql, str) or not sql.strip():
157
+ raise ProgrammingError("the statement is empty", codes.INVALID_INPUT)
158
+ self.sql = sql
159
+ self.kind = _sql.classify(sql)
160
+ if self.kind is _sql.StatementKind.DDL and parameters:
161
+ raise ProgrammingError("a DDL statement takes no parameters", codes.INVALID_INPUT)
162
+ self.parameters = encode_parameters(parameters)
163
+
164
+ @property
165
+ def route(self) -> str:
166
+ if self.kind is _sql.StatementKind.QUERY:
167
+ return proto.EXECUTE_QUERY
168
+ if self.kind is _sql.StatementKind.DDL:
169
+ return proto.EXECUTE_DDL
170
+ return proto.EXECUTE_NON_QUERY
171
+
172
+ @property
173
+ def begins_transaction(self) -> bool:
174
+ # A schema change commits on its own, so it does not open the implicit transaction.
175
+ return self.kind is not _sql.StatementKind.DDL
176
+
177
+ def check_transaction(self, txn: proto.TransactionHandle | None) -> None:
178
+ if txn is not None and _sql.runs_in_own_transaction(self.sql):
179
+ raise ProgrammingError(
180
+ "TRUNCATE runs in its own transaction, which a ROLLBACK cannot undo, so it is refused "
181
+ "inside a transaction. Commit or roll back first, or use autocommit",
182
+ codes.STATEMENT_NOT_ALLOWED_IN_TRANSACTION,
183
+ )
184
+
185
+ def body(self, state: ConnectionState, txn: proto.TransactionHandle | None) -> dict[str, Any]:
186
+ # Reads run in a snapshot with no locking mode, so only writes carry the autocommit options.
187
+ autocommit = state.options() if self.kind is not _sql.StatementKind.QUERY else None
188
+ return proto.sql_body(state.database, self.sql, self.parameters, txn, autocommit)
189
+
190
+ def result(self, body: Mapping[str, Any]) -> proto.Result:
191
+ if self.kind is _sql.StatementKind.QUERY:
192
+ return proto.parse_query(body)
193
+ if self.kind is _sql.StatementKind.DDL:
194
+ proto.expect_ok(body, "DDL statement failed")
195
+ return proto.Result(None, [], -1)
196
+ return proto.parse_non_query(body)
197
+
198
+
199
+ class StreamPlan(Plan):
200
+ """A statement for the streaming route: a read, or a write with a RETURNING list."""
201
+
202
+ def __init__(self, sql: str, parameters: Mapping[str, Any] | None) -> None:
203
+ super().__init__(sql, parameters)
204
+ if self.kind is _sql.StatementKind.DDL:
205
+ raise ProgrammingError(
206
+ "stream() runs queries; use execute() for DDL", codes.INVALID_INPUT
207
+ )
208
+ self.write = _sql.is_dml(sql)
209
+ # A write without RETURNING has no rows to stream; it runs on its own route.
210
+ self.streams = not self.write or _sql.has_returning(sql)
211
+
212
+ def body(self, state: ConnectionState, txn: proto.TransactionHandle | None) -> dict[str, Any]:
213
+ autocommit = state.options() if self.write else None
214
+ return proto.sql_body(state.database, self.sql, self.parameters, txn, autocommit)
215
+
216
+
217
+ class CursorState:
218
+ """The result buffer and the PEP 249 attributes of a cursor."""
219
+
220
+ def __init__(self, row_factory: RowFactory) -> None:
221
+ self.row_factory = row_factory
222
+ self.arraysize = 1
223
+ self.description: tuple[tuple[Any, ...], ...] | None = None
224
+ self.rowcount = -1
225
+ self.closed = False
226
+ self._rows: list[tuple[Any, ...]] = []
227
+ self._pos = 0
228
+ self._make: RowMaker = tuple_row(())
229
+
230
+ @property
231
+ def rownumber(self) -> int | None:
232
+ return self._pos if self.description is not None else None
233
+
234
+ def reset(self) -> None:
235
+ self.description = None
236
+ self.rowcount = -1
237
+ self._rows = []
238
+ self._pos = 0
239
+
240
+ def set_result(self, result: proto.Result) -> None:
241
+ self.description = proto.description(result.schema)
242
+ self._make = (
243
+ self.row_factory(self.description) if self.description is not None else tuple_row(())
244
+ )
245
+ self._rows = result.rows
246
+ self._pos = 0
247
+ self.rowcount = result.rowcount
248
+
249
+ def set_streaming(self, description: tuple[tuple[Any, ...], ...] | None) -> RowMaker:
250
+ self.description = description
251
+ self._rows = []
252
+ self._pos = 0
253
+ self.rowcount = -1
254
+ self._make = self.row_factory(description) if description is not None else tuple_row(())
255
+ return self._make
256
+
257
+ def check_open(self) -> None:
258
+ if self.closed:
259
+ raise InterfaceError("the cursor is closed")
260
+
261
+ def check_result(self) -> None:
262
+ self.check_open()
263
+ if self.description is None:
264
+ raise ProgrammingError("the last statement returned no rows", codes.INVALID_INPUT)
265
+
266
+ def fetchone(self) -> Any:
267
+ self.check_result()
268
+ if self._pos >= len(self._rows):
269
+ return None
270
+ row = self._rows[self._pos]
271
+ self._pos += 1
272
+ return self._make(row)
273
+
274
+ def fetchmany(self, size: int | None = None) -> list[Any]:
275
+ self.check_result()
276
+ count = self.arraysize if size is None else size
277
+ rows = self._rows[self._pos : self._pos + max(count, 0)]
278
+ self._pos += len(rows)
279
+ return [self._make(row) for row in rows]
280
+
281
+ def fetchall(self) -> list[Any]:
282
+ self.check_result()
283
+ rows = self._rows[self._pos :]
284
+ self._pos = len(self._rows)
285
+ return [self._make(row) for row in rows]
286
+
287
+ def scroll(self, value: int, mode: str = "relative") -> None:
288
+ self.check_result()
289
+ position = self._pos + value if mode == "relative" else value
290
+ if mode not in ("relative", "absolute"):
291
+ raise ProgrammingError(
292
+ f"scroll mode {mode!r} is not 'relative' or 'absolute'", codes.INVALID_INPUT
293
+ )
294
+ if not 0 <= position <= len(self._rows):
295
+ raise IndexError("scroll target is out of the result set")
296
+ self._pos = position
297
+
298
+ def iter_buffered(self) -> Iterator[Any]:
299
+ while (row := self.fetchone()) is not None:
300
+ yield row