pgtern 0.3.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.
pgtern/errors.py ADDED
@@ -0,0 +1,248 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """DB-API 2.0 exceptions, plus one subclass per common SQLSTATE.
3
+
4
+ Server errors are raised as the class registered for their SQLSTATE
5
+ (``errors.UniqueViolation`` for ``23505``, ...); unknown codes fall back to the DB-API
6
+ class for their first two characters (``DatabaseError`` if even that is unknown).
7
+ ``str(exc)`` starts with the server's primary message; DETAIL, HINT and CONTEXT follow
8
+ on later lines. The class layout follows the DB-API conventions.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any, Callable
13
+
14
+
15
+ class Warning(Exception): # noqa: A001 - the name is required by DB-API 2.0
16
+ pass
17
+
18
+
19
+ _DIAG_FIELDS = {
20
+ "severity": "S", "severity_nonlocalized": "V", "sqlstate": "C", "message_primary": "M",
21
+ "message_detail": "D", "message_hint": "H", "statement_position": "P",
22
+ "internal_position": "p", "internal_query": "q", "context": "W", "schema_name": "s",
23
+ "table_name": "t", "column_name": "c", "datatype_name": "d", "constraint_name": "n",
24
+ "source_file": "F", "source_line": "L", "source_function": "R",
25
+ }
26
+
27
+
28
+ class Diagnostic:
29
+ """The fields of a server ErrorResponse (``diag.message_primary``, ``diag.constraint_name``, ...)."""
30
+
31
+ __slots__ = ("_fields",)
32
+
33
+ if TYPE_CHECKING: # the properties below are generated at import time; declared here for type checkers
34
+ severity: str | None
35
+ severity_nonlocalized: str | None
36
+ sqlstate: str | None
37
+ message_primary: str | None
38
+ message_detail: str | None
39
+ message_hint: str | None
40
+ statement_position: str | None
41
+ internal_position: str | None
42
+ internal_query: str | None
43
+ context: str | None
44
+ schema_name: str | None
45
+ table_name: str | None
46
+ column_name: str | None
47
+ datatype_name: str | None
48
+ constraint_name: str | None
49
+ source_file: str | None
50
+ source_line: str | None
51
+ source_function: str | None
52
+
53
+ def __init__(self, fields: dict[str, str]) -> None:
54
+ self._fields = dict(fields)
55
+
56
+ def __repr__(self) -> str:
57
+ return f"Diagnostic(sqlstate={self.sqlstate!r}, message_primary={self.message_primary!r})"
58
+
59
+
60
+ def _field_getter(code: str) -> Callable[[Diagnostic], str | None]:
61
+ def get(self: Diagnostic) -> str | None:
62
+ return self._fields.get(code)
63
+ return get
64
+
65
+
66
+ for _name, _code in _DIAG_FIELDS.items():
67
+ setattr(Diagnostic, _name, property(_field_getter(_code)))
68
+
69
+
70
+ class Error(Exception):
71
+ sqlstate: str | None = None
72
+
73
+ def __init__(self, *args: Any, diag: Diagnostic | None = None) -> None:
74
+ super().__init__(*args)
75
+ self.diag = diag if diag is not None else Diagnostic({})
76
+
77
+
78
+ class InterfaceError(Error): ...
79
+ class DatabaseError(Error): ...
80
+ class DataError(DatabaseError): ...
81
+ class OperationalError(DatabaseError): ...
82
+ class IntegrityError(DatabaseError): ...
83
+ class InternalError(DatabaseError): ...
84
+ class ProgrammingError(DatabaseError): ...
85
+ class NotSupportedError(DatabaseError): ...
86
+
87
+
88
+ class PipelineAborted(OperationalError):
89
+ """A statement in a pipeline did not run because an earlier statement in the same batch failed."""
90
+
91
+
92
+ # SQLSTATE class (first two characters) -> DB-API base class. Any class not listed here
93
+ # (03, 09, 0B, 0F, 0L, 0P, 72, ...) maps to plain DatabaseError. The table is checked against
94
+ # unknown codes are covered by tests/test_unit.py (test_unknown_sqlstates_fall_back_to_their_dbapi_class).
95
+ _BASE_BY_CLASS: dict[str, type[Error]] = {
96
+ "08": OperationalError, "0A": NotSupportedError, "20": ProgrammingError, "21": ProgrammingError,
97
+ "22": DataError, "23": IntegrityError, "24": InternalError, "25": InternalError,
98
+ "26": ProgrammingError, "27": OperationalError, "28": OperationalError, "2B": InternalError,
99
+ "2D": InternalError, "2F": OperationalError, "34": ProgrammingError, "38": OperationalError,
100
+ "39": OperationalError, "3B": OperationalError, "3D": ProgrammingError, "3F": ProgrammingError,
101
+ "40": OperationalError, "42": ProgrammingError, "44": ProgrammingError, "53": OperationalError,
102
+ "54": OperationalError, "55": OperationalError, "57": OperationalError, "58": OperationalError,
103
+ "F0": OperationalError, "HV": OperationalError, "P0": ProgrammingError, "XX": InternalError,
104
+ }
105
+
106
+ # "<sqlstate> <ClassName>". Codes ending in 000 are class-level parents of the rest of their class.
107
+ _SPEC = """
108
+ 08000 ConnectionException
109
+ 08001 SqlclientUnableToEstablishSqlconnection
110
+ 08003 ConnectionDoesNotExist
111
+ 08004 SqlserverRejectedEstablishmentOfSqlconnection
112
+ 08006 ConnectionFailure
113
+ 08P01 ProtocolViolation
114
+ 0A000 FeatureNotSupported
115
+ 21000 CardinalityViolation
116
+ 22000 DataException
117
+ 22001 StringDataRightTruncation
118
+ 22002 NullValueNoIndicatorParameter
119
+ 22003 NumericValueOutOfRange
120
+ 22007 InvalidDatetimeFormat
121
+ 22008 DatetimeFieldOverflow
122
+ 22012 DivisionByZero
123
+ 22021 CharacterNotInRepertoire
124
+ 22023 InvalidParameterValue
125
+ 22026 StringDataLengthMismatch
126
+ 22P02 InvalidTextRepresentation
127
+ 22P03 InvalidBinaryRepresentation
128
+ 22P05 UntranslatableCharacter
129
+ 23000 IntegrityConstraintViolation
130
+ 23001 RestrictViolation
131
+ 23502 NotNullViolation
132
+ 23503 ForeignKeyViolation
133
+ 23505 UniqueViolation
134
+ 23514 CheckViolation
135
+ 23P01 ExclusionViolation
136
+ 24000 InvalidCursorState
137
+ 25000 InvalidTransactionState
138
+ 25001 ActiveSqlTransaction
139
+ 25006 ReadOnlySqlTransaction
140
+ 25P01 NoActiveSqlTransaction
141
+ 25P02 InFailedSqlTransaction
142
+ 25P03 IdleInTransactionSessionTimeout
143
+ 26000 InvalidSqlStatementName
144
+ 28000 InvalidAuthorizationSpecification
145
+ 28P01 InvalidPassword
146
+ 2BP01 DependentObjectsStillExist
147
+ 2D000 InvalidTransactionTermination
148
+ 34000 InvalidCursorName
149
+ 38000 ExternalRoutineException
150
+ 3B000 SavepointException
151
+ 3D000 InvalidCatalogName
152
+ 3F000 InvalidSchemaName
153
+ 40000 TransactionRollback
154
+ 40001 SerializationFailure
155
+ 40002 TransactionIntegrityConstraintViolation
156
+ 40003 StatementCompletionUnknown
157
+ 40P01 DeadlockDetected
158
+ 42000 SyntaxErrorOrAccessRuleViolation
159
+ 42501 InsufficientPrivilege
160
+ 42601 SyntaxError
161
+ 42602 InvalidName
162
+ 42611 InvalidColumnDefinition
163
+ 42622 NameTooLong
164
+ 42701 DuplicateColumn
165
+ 42702 AmbiguousColumn
166
+ 42703 UndefinedColumn
167
+ 42704 UndefinedObject
168
+ 42710 DuplicateObject
169
+ 42712 DuplicateAlias
170
+ 42723 DuplicateFunction
171
+ 42725 AmbiguousFunction
172
+ 42803 GroupingError
173
+ 42804 DatatypeMismatch
174
+ 42809 WrongObjectType
175
+ 42830 InvalidForeignKey
176
+ 42846 CannotCoerce
177
+ 42883 UndefinedFunction
178
+ 42939 ReservedName
179
+ 42P01 UndefinedTable
180
+ 42P02 UndefinedParameter
181
+ 42P03 DuplicateCursor
182
+ 42P04 DuplicateDatabase
183
+ 42P05 DuplicatePreparedStatement
184
+ 42P06 DuplicateSchema
185
+ 42P07 DuplicateTable
186
+ 42P08 AmbiguousParameter
187
+ 42P09 AmbiguousAlias
188
+ 42P10 InvalidColumnReference
189
+ 42P16 InvalidTableDefinition
190
+ 42P17 InvalidObjectDefinition
191
+ 42P18 IndeterminateDatatype
192
+ 44000 WithCheckOptionViolation
193
+ 53000 InsufficientResources
194
+ 53100 DiskFull
195
+ 53200 OutOfMemory
196
+ 53300 TooManyConnections
197
+ 54000 ProgramLimitExceeded
198
+ 55000 ObjectNotInPrerequisiteState
199
+ 55006 ObjectInUse
200
+ 55P02 CantChangeRuntimeParam
201
+ 55P03 LockNotAvailable
202
+ 57000 OperatorIntervention
203
+ 57014 QueryCanceled
204
+ 57P01 AdminShutdown
205
+ 57P02 CrashShutdown
206
+ 57P03 CannotConnectNow
207
+ 57P04 DatabaseDropped
208
+ 58000 SystemError
209
+ 58030 IoError
210
+ P0000 PlpgsqlError
211
+ P0001 RaiseException
212
+ P0002 NoDataFound
213
+ P0003 TooManyRows
214
+ P0004 AssertFailure
215
+ XX000 InternalError_
216
+ XX001 DataCorrupted
217
+ XX002 IndexCorrupted
218
+ """
219
+
220
+ _BY_SQLSTATE: dict[str, type[Error]] = {}
221
+ _ENTRIES = [line.split() for line in _SPEC.strip().splitlines()]
222
+
223
+ # The hierarchy is flat: every specific class derives directly from its DB-API
224
+ # family (UniqueViolation -> IntegrityError).
225
+ for _code, _name in _ENTRIES:
226
+ _cls = type(_name, (_BASE_BY_CLASS.get(_code[:2], DatabaseError),),
227
+ {"sqlstate": _code, "__module__": __name__})
228
+ globals()[_name] = _cls
229
+ _BY_SQLSTATE[_code] = _cls
230
+
231
+ __all__ = [
232
+ "Warning", "Error", "InterfaceError", "DatabaseError", "DataError", "OperationalError",
233
+ "IntegrityError", "InternalError", "ProgrammingError", "NotSupportedError", "Diagnostic", "PipelineAborted",
234
+ *(name for _, name in _ENTRIES),
235
+ ]
236
+
237
+
238
+ def error_from_fields(fields: dict[str, str]) -> Error:
239
+ """Build the right exception from the fields of a server ErrorResponse."""
240
+ sqlstate = fields.get("C") or "XX000"
241
+ cls = _BY_SQLSTATE.get(sqlstate) or _BASE_BY_CLASS.get(sqlstate[:2], DatabaseError)
242
+ lines = [fields.get("M", "unknown error")]
243
+ for key, label in (("D", "DETAIL"), ("H", "HINT"), ("W", "CONTEXT")):
244
+ if key in fields:
245
+ lines.append(f"{label}: {fields[key]}")
246
+ exc = cls("\n".join(lines), diag=Diagnostic(fields))
247
+ exc.sqlstate = sqlstate
248
+ return exc
pgtern/hooks.py ADDED
@@ -0,0 +1,53 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Query hooks: observe every statement without monkeypatching.
3
+
4
+ A hook is a callable ``hook(info) -> context manager``. The context manager is
5
+ entered before the statement is sent and exited when its result (or error) has
6
+ been read, with the usual ``__exit__`` exception arguments. ``info.rowcount`` is
7
+ filled in before a successful exit.
8
+
9
+ Hooks never receive bound parameter values. ``info.statement`` is the query text;
10
+ for ``sql.Composed`` queries any ``sql.Literal`` is replaced by ``?``.
11
+
12
+ @contextmanager
13
+ def trace(info):
14
+ with tracer.start_as_current_span("postgresql") as span:
15
+ span.set_attribute("db.statement", info.statement)
16
+ yield
17
+
18
+ unregister = pgtern.add_query_hook(trace)
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from contextlib import AbstractContextManager
23
+ from dataclasses import dataclass
24
+ from typing import Callable
25
+
26
+ __all__ = ["QueryInfo", "add_query_hook"]
27
+
28
+
29
+ @dataclass
30
+ class QueryInfo:
31
+ statement: str
32
+ host: str
33
+ port: int
34
+ dbname: str
35
+ user: str
36
+ rowcount: int = -1
37
+
38
+
39
+ QueryHook = Callable[[QueryInfo], AbstractContextManager]
40
+ _HOOKS: list[QueryHook] = []
41
+
42
+
43
+ def add_query_hook(hook: QueryHook) -> Callable[[], None]:
44
+ """Register a hook for every connection; returns a function that unregisters it."""
45
+ _HOOKS.append(hook)
46
+
47
+ def remove() -> None:
48
+ try:
49
+ _HOOKS.remove(hook)
50
+ except ValueError:
51
+ pass
52
+
53
+ return remove
pgtern/pipeline.py ADDED
@@ -0,0 +1,202 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Pipeline mode: send many statements before waiting for any reply.
3
+
4
+ with conn.pipeline() as pipe:
5
+ a = pipe.execute("INSERT INTO t (v) VALUES (%s)", [1])
6
+ b = pipe.execute("SELECT count(*) FROM t")
7
+ # leaving the block synchronizes: now a and b have their results
8
+ print(b.fetchone())
9
+
10
+ Statements are sent immediately and their replies read, in order, at a *sync point*. Sync
11
+ points happen when you call ``sync()``, when the block ends, and every ``batch_size``
12
+ statements.
13
+
14
+ * Transactions: with ``autocommit=False`` the first statement of a batch is preceded by an
15
+ implicit BEGIN, as usual. With ``autocommit=True`` every sync point is a commit point, so
16
+ statements in one batch succeed or fail together. Use ``batch_size`` or ``sync()`` to choose.
17
+ * Errors: when one statement fails the server skips the rest of the batch. Those statements
18
+ get :class:`pgtern.errors.PipelineAborted`, and the failing statement raises its own error
19
+ when read. Inside a transaction you must roll back afterwards, as with any failure.
20
+ * Only text format is used (``binary=True`` is not available in pipelines).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from typing import Any
26
+
27
+ from . import _protocol as P
28
+ from . import _query, _types, errors, hooks
29
+ from ._cursor import Cursor
30
+
31
+ log = logging.getLogger("pgtern")
32
+
33
+ _IDLE = "I"
34
+
35
+
36
+ def _enter_hooks(conn: Any, item: "_Item") -> tuple[hooks.QueryInfo | None, list]:
37
+ if item.hidden or not hooks._HOOKS:
38
+ return None, []
39
+ info = hooks.QueryInfo(item.redacted, conn._host, conn._port, conn._params.dbname, conn._params.user)
40
+ entered = []
41
+ for hook in tuple(hooks._HOOKS):
42
+ try:
43
+ cm = hook(info)
44
+ cm.__enter__()
45
+ entered.append(cm)
46
+ except Exception:
47
+ log.exception("a pgtern query hook failed on entry; ignoring it")
48
+ return info, entered
49
+
50
+
51
+ def _exit_hooks(entered: list, error: BaseException | None) -> None:
52
+ exc_info = (type(error), error, None) if error is not None else (None, None, None)
53
+ for cm in reversed(entered):
54
+ try:
55
+ cm.__exit__(*exc_info)
56
+ except Exception:
57
+ log.exception("a pgtern query hook failed on exit; ignoring it")
58
+
59
+
60
+ class _Item:
61
+ __slots__ = ("cursor", "sql", "redacted", "hidden")
62
+
63
+ def __init__(self, cursor: Cursor | None, sql: str, redacted: str, hidden: bool = False) -> None:
64
+ self.cursor = cursor
65
+ self.sql = sql
66
+ self.redacted = redacted
67
+ self.hidden = hidden
68
+
69
+
70
+ def _encode(sql: str, params: Any) -> bytes:
71
+ if params is None:
72
+ return P.extended_query(sql, [], [], sync=False)
73
+ pg_sql, values = _query.translate(sql, params)
74
+ dumped = [_types.dump(v) for v in values]
75
+ return P.extended_query(pg_sql, [oid for oid, _ in dumped], [data for _, data in dumped], sync=False)
76
+
77
+
78
+ class Pipeline:
79
+ """Synchronous pipeline. Create it with :meth:`pgtern.Connection.pipeline`."""
80
+
81
+ def __init__(self, conn: Any, batch_size: int = 64) -> None:
82
+ if batch_size < 1:
83
+ raise ValueError("batch_size must be at least 1")
84
+ self._conn = conn
85
+ self._batch_size = batch_size
86
+ self._queue: list[_Item] = []
87
+ self._begin_queued = False
88
+ self._open = False
89
+
90
+ # ---------------------------------------------------------------- block
91
+ def __enter__(self) -> "Pipeline":
92
+ conn = self._conn
93
+ with conn._lock:
94
+ conn._check_open()
95
+ if conn._pipeline is not None:
96
+ raise errors.ProgrammingError("a pipeline is already active on this connection")
97
+ conn._pipeline = self
98
+ self._open = True
99
+ return self
100
+
101
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
102
+ try:
103
+ if exc_type is None:
104
+ self.sync()
105
+ elif not self._conn.closed:
106
+ try:
107
+ self.sync() # read the replies so the connection stays usable
108
+ except Exception: # noqa: BLE001 - the original exception is what matters
109
+ pass
110
+ finally:
111
+ self._open = False
112
+ with self._conn._lock:
113
+ if self._conn._pipeline is self:
114
+ self._conn._pipeline = None
115
+
116
+ # ---------------------------------------------------------------- queueing
117
+ def execute(self, query: Any, params: Any = None) -> Cursor:
118
+ """Queue a statement; returns a cursor that holds its result after the next sync."""
119
+ conn = self._conn
120
+ with conn._lock:
121
+ conn._check_open()
122
+ if not self._open:
123
+ raise errors.ProgrammingError("the pipeline is not active (use it in a 'with' block)")
124
+ sql, redacted = conn._query_text(query)
125
+ if len(self._queue) >= self._batch_size:
126
+ self.sync()
127
+ if not conn._autocommit and conn._tx_status == _IDLE and not self._begin_queued:
128
+ conn._send(P.extended_query("BEGIN", [], [], sync=False))
129
+ self._queue.append(_Item(None, "BEGIN", "BEGIN", hidden=True))
130
+ self._begin_queued = True
131
+ cursor = Cursor(conn)
132
+ cursor._pipeline_pending = True
133
+ conn._send(_encode(sql, params))
134
+ self._queue.append(_Item(cursor, sql, redacted))
135
+ return cursor
136
+
137
+ # ---------------------------------------------------------------- sync point
138
+ def sync(self) -> None:
139
+ """Send Sync, read every queued reply and fill in the cursors."""
140
+ conn = self._conn
141
+ with conn._lock:
142
+ conn._check_open()
143
+ items, self._queue = self._queue, []
144
+ self._begin_queued = False
145
+ conn._send(P.SYNC)
146
+ failure: errors.Error | None = None
147
+ collected: list[tuple[Cursor, list]] = []
148
+ for item in items:
149
+ if failure is not None:
150
+ if item.cursor is not None:
151
+ item.cursor._pipeline_pending = False
152
+ item.cursor._pipeline_error = errors.PipelineAborted(
153
+ "not executed: an earlier statement in the pipeline failed")
154
+ continue
155
+ info, entered = _enter_hooks(conn, item)
156
+ results, error = self._read_reply(conn)
157
+ if info is not None and results is not None:
158
+ info.rowcount = results[-1].rowcount if results else -1
159
+ _exit_hooks(entered, error)
160
+ if error is not None:
161
+ failure = error
162
+ if item.cursor is not None:
163
+ item.cursor._pipeline_pending = False
164
+ item.cursor._pipeline_error = error
165
+ elif item.cursor is not None and results is not None:
166
+ collected.append((item.cursor, results))
167
+ while True: # the rest of a failed batch sends nothing but ReadyForQuery
168
+ tag, payload = conn._next_message()
169
+ if tag == 0x5A:
170
+ conn._tx_status = chr(payload[0])
171
+ break
172
+ if tag == 0x45:
173
+ fields = P.parse_error_fields(payload)
174
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
175
+ conn._mark_broken()
176
+ raise errors.error_from_fields(fields)
177
+ else:
178
+ conn._handle_async_message(tag, payload)
179
+ conn._prime_loaders([r for _, res in collected for r in res])
180
+ for cursor, res in collected:
181
+ cursor._pipeline_pending = False
182
+ cursor._set_results(res)
183
+
184
+ @staticmethod
185
+ def _read_reply(conn: Any) -> tuple[list | None, errors.Error | None]:
186
+ """Read one statement's messages. Returns (results, None), or (None, error)."""
187
+ collector = P.QueryCollector()
188
+ while True:
189
+ tag, payload = conn._next_message()
190
+ if tag == 0x45:
191
+ fields = P.parse_error_fields(payload)
192
+ if fields.get("V", fields.get("S")) in ("FATAL", "PANIC"):
193
+ conn._mark_broken()
194
+ raise errors.error_from_fields(fields)
195
+ return None, errors.error_from_fields(fields)
196
+ if tag in (0x43, 0x49): # C CommandComplete, I EmptyQueryResponse: this statement is done
197
+ collector.handle(tag, payload)
198
+ return collector.results, None
199
+ if tag in (0x4E, 0x53, 0x41):
200
+ conn._handle_async_message(tag, payload)
201
+ else:
202
+ collector.handle(tag, payload)