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/__init__.py +46 -0
- pgtern/_binary.py +226 -0
- pgtern/_composite.py +103 -0
- pgtern/_conn.py +907 -0
- pgtern/_copy.py +186 -0
- pgtern/_cursor.py +261 -0
- pgtern/_dsn.py +182 -0
- pgtern/_protocol.py +258 -0
- pgtern/_query.py +69 -0
- pgtern/_scram.py +101 -0
- pgtern/_types.py +442 -0
- pgtern/adapters.py +85 -0
- pgtern/aio.py +1287 -0
- pgtern/errors.py +248 -0
- pgtern/hooks.py +53 -0
- pgtern/pipeline.py +202 -0
- pgtern/pool.py +309 -0
- pgtern/py.typed +0 -0
- pgtern/rows.py +31 -0
- pgtern/sql.py +200 -0
- pgtern/types/__init__.py +30 -0
- pgtern/types/json.py +5 -0
- pgtern-0.3.0.dist-info/METADATA +265 -0
- pgtern-0.3.0.dist-info/RECORD +27 -0
- pgtern-0.3.0.dist-info/WHEEL +5 -0
- pgtern-0.3.0.dist-info/licenses/LICENSE +202 -0
- pgtern-0.3.0.dist-info/top_level.txt +1 -0
pgtern/_copy.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""COPY support: text-format helpers and the synchronous ``Copy`` object."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import ExitStack
|
|
6
|
+
from typing import Any, Iterator, Sequence
|
|
7
|
+
|
|
8
|
+
from . import _protocol as P
|
|
9
|
+
from . import _types, errors
|
|
10
|
+
|
|
11
|
+
_ESCAPES = {ord("\\"): "\\\\", ord("\t"): "\\t", ord("\n"): "\\n", ord("\r"): "\\r"}
|
|
12
|
+
_UNESCAPE = {b"b": b"\b", b"f": b"\f", b"n": b"\n", b"r": b"\r", b"t": b"\t", b"v": b"\v", b"\\": b"\\"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_row(row: Sequence[Any]) -> bytes:
|
|
16
|
+
"""One row in COPY text format (tab separated, ``\\N`` for NULL), newline terminated."""
|
|
17
|
+
fields = []
|
|
18
|
+
for value in row:
|
|
19
|
+
_oid, data = _types.dump(value)
|
|
20
|
+
fields.append("\\N" if data is None else data.decode("utf-8").translate(_ESCAPES))
|
|
21
|
+
return ("\t".join(fields) + "\n").encode("utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _unescape(field: bytes) -> str | None:
|
|
25
|
+
if field == b"\\N":
|
|
26
|
+
return None
|
|
27
|
+
if b"\\" not in field:
|
|
28
|
+
return field.decode("utf-8")
|
|
29
|
+
out = bytearray()
|
|
30
|
+
i, n = 0, len(field)
|
|
31
|
+
while i < n:
|
|
32
|
+
c = field[i:i + 1]
|
|
33
|
+
if c != b"\\" or i + 1 >= n:
|
|
34
|
+
out += c
|
|
35
|
+
i += 1
|
|
36
|
+
continue
|
|
37
|
+
nxt = field[i + 1:i + 2]
|
|
38
|
+
if nxt in _UNESCAPE:
|
|
39
|
+
out += _UNESCAPE[nxt]
|
|
40
|
+
i += 2
|
|
41
|
+
elif nxt == b"x":
|
|
42
|
+
j = i + 2
|
|
43
|
+
while j < n and j < i + 4 and chr(field[j]) in "0123456789abcdefABCDEF":
|
|
44
|
+
j += 1
|
|
45
|
+
out.append(int(field[i + 2:j], 16)) if j > i + 2 else out.extend(b"x")
|
|
46
|
+
i = max(j, i + 2)
|
|
47
|
+
elif nxt in b"01234567":
|
|
48
|
+
j = i + 1
|
|
49
|
+
while j < n and j < i + 4 and field[j:j + 1] in (b"0", b"1", b"2", b"3", b"4", b"5", b"6", b"7"):
|
|
50
|
+
j += 1
|
|
51
|
+
out.append(int(field[i + 1:j], 8) & 0xFF)
|
|
52
|
+
i = j
|
|
53
|
+
else:
|
|
54
|
+
out += nxt
|
|
55
|
+
i += 2
|
|
56
|
+
return out.decode("utf-8")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_rows(buffer: bytearray, final: bool = False) -> list[tuple]:
|
|
60
|
+
"""Consume complete text-format lines from ``buffer``; return them as tuples of str/None."""
|
|
61
|
+
rows: list[tuple] = []
|
|
62
|
+
while True:
|
|
63
|
+
end = buffer.find(b"\n")
|
|
64
|
+
if end < 0:
|
|
65
|
+
if final and buffer:
|
|
66
|
+
end = len(buffer)
|
|
67
|
+
else:
|
|
68
|
+
return rows
|
|
69
|
+
line = bytes(buffer[:end])
|
|
70
|
+
del buffer[:end + 1]
|
|
71
|
+
rows.append(tuple(_unescape(f) for f in line.split(b"\t")))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Copy:
|
|
75
|
+
"""``with cursor.copy("COPY t FROM STDIN") as copy: copy.write_row(...)`` or
|
|
76
|
+
``with cursor.copy("COPY t TO STDOUT") as copy: for chunk in copy: ...``."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, cursor: Any, query: Any, params: Any = None) -> None:
|
|
79
|
+
self._cursor = cursor
|
|
80
|
+
self._conn = cursor.connection
|
|
81
|
+
self._query = query
|
|
82
|
+
self._params = params
|
|
83
|
+
self._kind = ""
|
|
84
|
+
self._stack = ExitStack()
|
|
85
|
+
self._done = False
|
|
86
|
+
self._buffer = bytearray()
|
|
87
|
+
self._results: list = []
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------- context
|
|
90
|
+
def __enter__(self) -> "Copy":
|
|
91
|
+
conn = self._conn
|
|
92
|
+
conn._lock.acquire()
|
|
93
|
+
try:
|
|
94
|
+
self._kind = conn._start_copy(self._query, self._params, self._stack)
|
|
95
|
+
except BaseException:
|
|
96
|
+
self._stack.close()
|
|
97
|
+
conn._lock.release()
|
|
98
|
+
raise
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
102
|
+
conn = self._conn
|
|
103
|
+
try:
|
|
104
|
+
if conn.closed:
|
|
105
|
+
return
|
|
106
|
+
if self._kind == "in":
|
|
107
|
+
if exc_type is not None:
|
|
108
|
+
conn._send(P.copy_fail(f"error in Python: {exc_type.__name__}"))
|
|
109
|
+
try:
|
|
110
|
+
conn._read_cycle()
|
|
111
|
+
except errors.Error:
|
|
112
|
+
pass # the expected "COPY from stdin failed"
|
|
113
|
+
else:
|
|
114
|
+
conn._send(P.COPY_DONE)
|
|
115
|
+
self._results = conn._read_cycle()
|
|
116
|
+
else:
|
|
117
|
+
while not self._done:
|
|
118
|
+
self._next_chunk()
|
|
119
|
+
self._results = self._finish()
|
|
120
|
+
if exc_type is None:
|
|
121
|
+
self._cursor._set_results(self._results)
|
|
122
|
+
finally:
|
|
123
|
+
try:
|
|
124
|
+
self._stack.__exit__(exc_type, exc, tb)
|
|
125
|
+
finally:
|
|
126
|
+
conn._lock.release()
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------- COPY FROM STDIN
|
|
129
|
+
def write(self, data: bytes | bytearray | memoryview | str) -> None:
|
|
130
|
+
if self._kind != "in":
|
|
131
|
+
raise errors.ProgrammingError("write() needs a COPY ... FROM STDIN statement")
|
|
132
|
+
if isinstance(data, str):
|
|
133
|
+
data = data.encode("utf-8")
|
|
134
|
+
data = bytes(data)
|
|
135
|
+
for i in range(0, max(len(data), 1), 1 << 20):
|
|
136
|
+
self._conn._send(P.copy_data(data[i:i + (1 << 20)]))
|
|
137
|
+
|
|
138
|
+
def write_row(self, row: Sequence[Any]) -> None:
|
|
139
|
+
"""Write one row in text format (``COPY ... FROM STDIN`` default, or ``WITH (FORMAT text)``)."""
|
|
140
|
+
self.write(format_row(row))
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------- COPY TO STDOUT
|
|
143
|
+
def _next_chunk(self) -> bytes | None:
|
|
144
|
+
conn = self._conn
|
|
145
|
+
while True:
|
|
146
|
+
tag, payload = conn._next_message()
|
|
147
|
+
if tag == 0x64: # d CopyData
|
|
148
|
+
return payload
|
|
149
|
+
if tag == 0x63: # c CopyDone
|
|
150
|
+
self._done = True
|
|
151
|
+
return None
|
|
152
|
+
if tag == 0x45:
|
|
153
|
+
self._done = True
|
|
154
|
+
self._pending_error = P.parse_error_fields(payload)
|
|
155
|
+
return None
|
|
156
|
+
conn._handle_async_message(tag, payload)
|
|
157
|
+
|
|
158
|
+
def _finish(self) -> list:
|
|
159
|
+
err = getattr(self, "_pending_error", None)
|
|
160
|
+
results = self._conn._read_cycle()
|
|
161
|
+
if err is not None:
|
|
162
|
+
raise errors.error_from_fields(err)
|
|
163
|
+
return results
|
|
164
|
+
|
|
165
|
+
def read(self) -> bytes:
|
|
166
|
+
"""The next chunk of COPY data, or ``b""`` at the end."""
|
|
167
|
+
if self._kind != "out":
|
|
168
|
+
raise errors.ProgrammingError("read() needs a COPY ... TO STDOUT statement")
|
|
169
|
+
if self._done:
|
|
170
|
+
return b""
|
|
171
|
+
chunk = self._next_chunk()
|
|
172
|
+
return chunk if chunk is not None else b""
|
|
173
|
+
|
|
174
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
175
|
+
while True:
|
|
176
|
+
chunk = self.read()
|
|
177
|
+
if not chunk:
|
|
178
|
+
return
|
|
179
|
+
yield chunk
|
|
180
|
+
|
|
181
|
+
def rows(self) -> Iterator[tuple]:
|
|
182
|
+
"""Parsed rows (tuples of ``str``/``None``) of a text-format ``COPY ... TO STDOUT``."""
|
|
183
|
+
for chunk in self:
|
|
184
|
+
self._buffer += chunk
|
|
185
|
+
yield from parse_rows(self._buffer)
|
|
186
|
+
yield from parse_rows(self._buffer, final=True)
|
pgtern/_cursor.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""DB-API cursor. Results are fully buffered on the client."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Iterator, Sequence
|
|
6
|
+
|
|
7
|
+
from . import errors
|
|
8
|
+
from ._copy import Copy
|
|
9
|
+
from ._protocol import Column, Result
|
|
10
|
+
from ._types import load_row, make_loaders
|
|
11
|
+
from .sql import SQL, Composable, quote_ident
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Cursor:
|
|
15
|
+
def __init__(self, connection: Any, *, row_factory: Callable | None = None) -> None:
|
|
16
|
+
self.connection = connection
|
|
17
|
+
self.row_factory = row_factory or connection.row_factory
|
|
18
|
+
self.arraysize = 1
|
|
19
|
+
self._results: list[Result] = []
|
|
20
|
+
self._index = 0
|
|
21
|
+
self._pos = 0
|
|
22
|
+
self._make_row: Callable | None = None
|
|
23
|
+
self._loaders: list | None = None
|
|
24
|
+
self._total_rowcount: int | None = None
|
|
25
|
+
self._closed = False
|
|
26
|
+
self._pipeline_pending = False # set by a pipeline until its batch is synchronized
|
|
27
|
+
self._pipeline_error: Exception | None = None
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------- state
|
|
30
|
+
def _current(self) -> Result | None:
|
|
31
|
+
if self._pipeline_pending:
|
|
32
|
+
raise errors.ProgrammingError("the pipeline is not synchronized yet: leave the pipeline block first")
|
|
33
|
+
if self._pipeline_error is not None:
|
|
34
|
+
raise self._pipeline_error
|
|
35
|
+
return self._results[self._index] if self._index < len(self._results) else None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def description(self) -> list[Column] | None:
|
|
39
|
+
cur = self._current()
|
|
40
|
+
return list(cur.columns) if cur is not None and cur.columns is not None else None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def rowcount(self) -> int:
|
|
44
|
+
if self._total_rowcount is not None:
|
|
45
|
+
return self._total_rowcount
|
|
46
|
+
cur = self._current()
|
|
47
|
+
return cur.rowcount if cur is not None else -1
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def statusmessage(self) -> str | None:
|
|
51
|
+
cur = self._current()
|
|
52
|
+
return cur.tag if cur is not None else None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def closed(self) -> bool:
|
|
56
|
+
return self._closed
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
self._closed = True
|
|
60
|
+
self._results = []
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> "Cursor":
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(self, *exc: Any) -> None:
|
|
66
|
+
self.close()
|
|
67
|
+
|
|
68
|
+
def _reset_position(self, index: int) -> None:
|
|
69
|
+
self._index = index
|
|
70
|
+
self._pos = 0
|
|
71
|
+
self._make_row = None
|
|
72
|
+
self._loaders = None
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------- execute
|
|
75
|
+
def execute(self, query: Any, params: Any = None, *, prepare: bool | None = None,
|
|
76
|
+
binary: bool | None = None) -> "Cursor":
|
|
77
|
+
"""Run a query. ``binary=True`` sends and receives binary format where the type supports
|
|
78
|
+
it (one extra round trip). ``prepare`` is accepted for DB-API compatibility and ignored:
|
|
79
|
+
pgtern always uses the unnamed statement."""
|
|
80
|
+
if self._closed:
|
|
81
|
+
raise errors.InterfaceError("the cursor is closed")
|
|
82
|
+
self._set_results(self.connection._execute(query, params, bool(binary)))
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def _set_results(self, results: list[Result]) -> None:
|
|
86
|
+
self._results = results
|
|
87
|
+
self._total_rowcount = None
|
|
88
|
+
self._reset_position(0)
|
|
89
|
+
|
|
90
|
+
def copy(self, query: Any, params: Any = None) -> Copy:
|
|
91
|
+
"""Run ``COPY ... FROM STDIN`` / ``TO STDOUT``; use the result as a context manager."""
|
|
92
|
+
if self._closed:
|
|
93
|
+
raise errors.InterfaceError("the cursor is closed")
|
|
94
|
+
return Copy(self, query, params)
|
|
95
|
+
|
|
96
|
+
def executemany(self, query: Any, params_seq: Sequence[Any]) -> None:
|
|
97
|
+
"""Run ``query`` once per parameter set as one pipelined batch, in one transaction
|
|
98
|
+
(see ``Connection._run_many``). ``rowcount`` is the total of the statements' rowcounts."""
|
|
99
|
+
if self._closed:
|
|
100
|
+
raise errors.InterfaceError("the cursor is closed")
|
|
101
|
+
total = self.connection._run_many(query, params_seq)
|
|
102
|
+
self._set_results([])
|
|
103
|
+
self._total_rowcount = total
|
|
104
|
+
|
|
105
|
+
def nextset(self) -> bool | None:
|
|
106
|
+
if self._index + 1 < len(self._results):
|
|
107
|
+
self._reset_position(self._index + 1)
|
|
108
|
+
return True
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------- fetch
|
|
112
|
+
def _prepare_rows(self) -> tuple[Result, list[Any]]:
|
|
113
|
+
cur = self._current()
|
|
114
|
+
if cur is None or cur.columns is None:
|
|
115
|
+
raise errors.ProgrammingError("the last operation didn't produce a result")
|
|
116
|
+
if self._loaders is None:
|
|
117
|
+
self._loaders = make_loaders([c.type_code for c in cur.columns], self.connection._dates_ok,
|
|
118
|
+
self.connection._loader_cache, cur.formats)
|
|
119
|
+
if self._make_row is None:
|
|
120
|
+
self._make_row = self.row_factory(self)
|
|
121
|
+
return cur, self._loaders
|
|
122
|
+
|
|
123
|
+
def fetchone(self) -> Any:
|
|
124
|
+
cur, loaders = self._prepare_rows()
|
|
125
|
+
if self._pos >= len(cur.rows):
|
|
126
|
+
return None
|
|
127
|
+
raw = cur.rows[self._pos]
|
|
128
|
+
self._pos += 1
|
|
129
|
+
return self._make_row(load_row(raw, loaders)) # type: ignore[misc]
|
|
130
|
+
|
|
131
|
+
def fetchmany(self, size: int | None = None) -> list:
|
|
132
|
+
cur, loaders = self._prepare_rows()
|
|
133
|
+
count = self.arraysize if size is None else size
|
|
134
|
+
chunk = cur.rows[self._pos:self._pos + count]
|
|
135
|
+
self._pos += len(chunk)
|
|
136
|
+
return [self._make_row(load_row(raw, loaders)) for raw in chunk] # type: ignore[misc]
|
|
137
|
+
|
|
138
|
+
def fetchall(self) -> list:
|
|
139
|
+
cur, loaders = self._prepare_rows()
|
|
140
|
+
chunk = cur.rows[self._pos:]
|
|
141
|
+
self._pos = len(cur.rows)
|
|
142
|
+
return [self._make_row(load_row(raw, loaders)) for raw in chunk] # type: ignore[misc]
|
|
143
|
+
|
|
144
|
+
def __iter__(self) -> Iterator:
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __next__(self) -> Any:
|
|
148
|
+
row = self.fetchone()
|
|
149
|
+
if row is None:
|
|
150
|
+
raise StopIteration
|
|
151
|
+
return row
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ServerCursor(Cursor):
|
|
155
|
+
"""A named cursor living in the server (``DECLARE``): rows are fetched in batches,
|
|
156
|
+
so large results never have to fit in memory. Needs a transaction unless ``withhold``."""
|
|
157
|
+
|
|
158
|
+
itersize = 100
|
|
159
|
+
|
|
160
|
+
def __init__(self, connection: Any, name: str, *, row_factory: Callable | None = None,
|
|
161
|
+
scrollable: bool | None = None, withhold: bool = False) -> None:
|
|
162
|
+
super().__init__(connection, row_factory=row_factory)
|
|
163
|
+
self.name = name
|
|
164
|
+
self._scrollable = scrollable
|
|
165
|
+
self._withhold = withhold
|
|
166
|
+
self._binary = False
|
|
167
|
+
self._declared = False
|
|
168
|
+
self._columns: list[Column] | None = None
|
|
169
|
+
self._fetched = 0
|
|
170
|
+
self._batch: Result | None = None
|
|
171
|
+
|
|
172
|
+
# -------------------------------------------------------------- state
|
|
173
|
+
@property
|
|
174
|
+
def description(self) -> list[Column] | None:
|
|
175
|
+
return list(self._columns) if self._columns is not None else None
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def rowcount(self) -> int:
|
|
179
|
+
return -1 if not self._declared else self._fetched
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def rownumber(self) -> int | None:
|
|
183
|
+
return self._fetched if self._declared else None
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def statusmessage(self) -> str | None:
|
|
187
|
+
return self._batch.tag if self._batch is not None else None
|
|
188
|
+
|
|
189
|
+
def close(self) -> None:
|
|
190
|
+
if self._declared and not self.connection.closed and self.connection.info.transaction_status.name != "INERROR":
|
|
191
|
+
try:
|
|
192
|
+
self.connection._execute(f"CLOSE {quote_ident(self.name)}", None)
|
|
193
|
+
except errors.Error:
|
|
194
|
+
pass
|
|
195
|
+
self._declared = False
|
|
196
|
+
super().close()
|
|
197
|
+
|
|
198
|
+
# -------------------------------------------------------------- execute
|
|
199
|
+
def execute(self, query: Any, params: Any = None, *, binary: bool | None = None, **_: Any) -> "ServerCursor":
|
|
200
|
+
if self._closed:
|
|
201
|
+
raise errors.InterfaceError("the cursor is closed")
|
|
202
|
+
self._binary = bool(binary)
|
|
203
|
+
if self._declared:
|
|
204
|
+
raise errors.ProgrammingError("the server-side cursor was already executed; create a new one")
|
|
205
|
+
prefix = f"DECLARE {quote_ident(self.name)} "
|
|
206
|
+
if self._scrollable is not None:
|
|
207
|
+
prefix += "SCROLL " if self._scrollable else "NO SCROLL "
|
|
208
|
+
prefix += "CURSOR " + ("WITH HOLD " if self._withhold else "") + "FOR "
|
|
209
|
+
body = query if isinstance(query, Composable) else SQL(query if isinstance(query, str) else bytes(query).decode())
|
|
210
|
+
self.connection._execute(SQL(prefix) + body, params)
|
|
211
|
+
self._declared = True
|
|
212
|
+
self._fetched = 0
|
|
213
|
+
first = self.connection._execute(f"FETCH FORWARD 0 FROM {quote_ident(self.name)}", None, self._binary)[0]
|
|
214
|
+
self._columns = first.columns
|
|
215
|
+
self._set_batch(first)
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def executemany(self, query: Any, params_seq: Sequence[Any]) -> None:
|
|
219
|
+
raise errors.NotSupportedError("executemany() can't be used with a server-side cursor")
|
|
220
|
+
|
|
221
|
+
def _set_batch(self, result: Result) -> None:
|
|
222
|
+
self._batch = result
|
|
223
|
+
self._set_results([result])
|
|
224
|
+
|
|
225
|
+
def _fetch(self, command: str) -> Result:
|
|
226
|
+
if not self._declared:
|
|
227
|
+
raise errors.ProgrammingError("the cursor has not been executed")
|
|
228
|
+
result = self.connection._execute(f"{command} FROM {quote_ident(self.name)}", None, self._binary)[0]
|
|
229
|
+
if result.columns is None:
|
|
230
|
+
result.columns = self._columns
|
|
231
|
+
self._set_batch(result)
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
# -------------------------------------------------------------- fetch
|
|
235
|
+
def fetchone(self) -> Any:
|
|
236
|
+
rows = self.fetchmany(1)
|
|
237
|
+
return rows[0] if rows else None
|
|
238
|
+
|
|
239
|
+
def fetchmany(self, size: int | None = None) -> list:
|
|
240
|
+
count = self.arraysize if size is None else size
|
|
241
|
+
result = self._fetch(f"FETCH FORWARD {int(count)}")
|
|
242
|
+
self._fetched += len(result.rows)
|
|
243
|
+
return Cursor.fetchall(self)
|
|
244
|
+
|
|
245
|
+
def fetchall(self) -> list:
|
|
246
|
+
result = self._fetch("FETCH FORWARD ALL")
|
|
247
|
+
self._fetched += len(result.rows)
|
|
248
|
+
return Cursor.fetchall(self)
|
|
249
|
+
|
|
250
|
+
def __iter__(self) -> Iterator:
|
|
251
|
+
while True:
|
|
252
|
+
rows = self.fetchmany(self.itersize)
|
|
253
|
+
yield from rows
|
|
254
|
+
if len(rows) < self.itersize:
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
def scroll(self, value: int, mode: str = "relative") -> None:
|
|
258
|
+
if mode not in ("relative", "absolute"):
|
|
259
|
+
raise ValueError(f"bad mode: {mode}; it should be 'relative' or 'absolute'")
|
|
260
|
+
self._fetch(f"MOVE {mode.upper()} {int(value)}")
|
|
261
|
+
self._fetched = (self._fetched + value) if mode == "relative" else value
|
pgtern/_dsn.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Connection strings: ``postgresql://`` URLs and ``key=value`` strings, plus ``PG*`` environment defaults."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import parse_qsl, unquote
|
|
9
|
+
|
|
10
|
+
from . import errors
|
|
11
|
+
|
|
12
|
+
SSL_MODES = ("disable", "allow", "prefer", "require", "verify-ca", "verify-full")
|
|
13
|
+
|
|
14
|
+
_ENV = {
|
|
15
|
+
"host": "PGHOST", "port": "PGPORT", "user": "PGUSER", "password": "PGPASSWORD",
|
|
16
|
+
"dbname": "PGDATABASE", "sslmode": "PGSSLMODE", "sslrootcert": "PGSSLROOTCERT",
|
|
17
|
+
"sslcert": "PGSSLCERT", "sslkey": "PGSSLKEY", "connect_timeout": "PGCONNECT_TIMEOUT",
|
|
18
|
+
"application_name": "PGAPPNAME", "options": "PGOPTIONS",
|
|
19
|
+
}
|
|
20
|
+
_KNOWN = set(_ENV) | {"database"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ConnParams:
|
|
25
|
+
hosts: list[tuple[str, int]]
|
|
26
|
+
user: str
|
|
27
|
+
password: str | None
|
|
28
|
+
dbname: str
|
|
29
|
+
sslmode: str = "prefer"
|
|
30
|
+
sslrootcert: str | None = None
|
|
31
|
+
sslcert: str | None = None
|
|
32
|
+
sslkey: str | None = None
|
|
33
|
+
connect_timeout: float | None = None
|
|
34
|
+
application_name: str | None = None
|
|
35
|
+
options: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_url(url: str) -> dict[str, str]:
|
|
39
|
+
scheme, _, rest = url.partition("://")
|
|
40
|
+
if scheme not in ("postgres", "postgresql"):
|
|
41
|
+
raise errors.ProgrammingError(f"invalid connection URL scheme {scheme!r}")
|
|
42
|
+
rest = rest.partition("#")[0]
|
|
43
|
+
rest, _, query = rest.partition("?")
|
|
44
|
+
authority, _, path = rest.partition("/")
|
|
45
|
+
userinfo, _, hostpart = authority.rpartition("@")
|
|
46
|
+
out: dict[str, str] = {}
|
|
47
|
+
if userinfo:
|
|
48
|
+
user, colon, password = userinfo.partition(":")
|
|
49
|
+
if user:
|
|
50
|
+
out["user"] = unquote(user)
|
|
51
|
+
if colon:
|
|
52
|
+
out["password"] = unquote(password)
|
|
53
|
+
hosts: list[str] = []
|
|
54
|
+
ports: list[str] = []
|
|
55
|
+
for item in hostpart.split(","):
|
|
56
|
+
if item.startswith("["):
|
|
57
|
+
end = item.index("]")
|
|
58
|
+
host, port = item[1:end], (item[end + 2:] if item[end + 1:end + 2] == ":" else "")
|
|
59
|
+
else:
|
|
60
|
+
host, _, port = item.partition(":")
|
|
61
|
+
hosts.append(unquote(host))
|
|
62
|
+
ports.append(port)
|
|
63
|
+
if any(hosts):
|
|
64
|
+
out["host"] = ",".join(hosts)
|
|
65
|
+
if any(ports):
|
|
66
|
+
out["port"] = ",".join(ports)
|
|
67
|
+
if path:
|
|
68
|
+
out["dbname"] = unquote(path)
|
|
69
|
+
for key, value in parse_qsl(query, keep_blank_values=True):
|
|
70
|
+
out[key] = value
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_keywords(s: str) -> dict[str, str]:
|
|
75
|
+
out: dict[str, str] = {}
|
|
76
|
+
i, n = 0, len(s)
|
|
77
|
+
while i < n:
|
|
78
|
+
while i < n and s[i].isspace():
|
|
79
|
+
i += 1
|
|
80
|
+
if i >= n:
|
|
81
|
+
break
|
|
82
|
+
j = i
|
|
83
|
+
while j < n and s[j] not in "= \t\r\n":
|
|
84
|
+
j += 1
|
|
85
|
+
key = s[i:j]
|
|
86
|
+
while j < n and s[j].isspace():
|
|
87
|
+
j += 1
|
|
88
|
+
if j >= n or s[j] != "=":
|
|
89
|
+
raise errors.ProgrammingError(f"missing '=' after {key!r} in connection info string")
|
|
90
|
+
j += 1
|
|
91
|
+
while j < n and s[j].isspace():
|
|
92
|
+
j += 1
|
|
93
|
+
if j < n and s[j] == "'":
|
|
94
|
+
j += 1
|
|
95
|
+
buf: list[str] = []
|
|
96
|
+
while True:
|
|
97
|
+
if j >= n:
|
|
98
|
+
raise errors.ProgrammingError("unterminated quoted string in connection info string")
|
|
99
|
+
c = s[j]
|
|
100
|
+
if c == "\\" and j + 1 < n:
|
|
101
|
+
buf.append(s[j + 1])
|
|
102
|
+
j += 2
|
|
103
|
+
elif c == "'":
|
|
104
|
+
j += 1
|
|
105
|
+
break
|
|
106
|
+
else:
|
|
107
|
+
buf.append(c)
|
|
108
|
+
j += 1
|
|
109
|
+
value = "".join(buf)
|
|
110
|
+
else:
|
|
111
|
+
k = j
|
|
112
|
+
while k < n and not s[k].isspace():
|
|
113
|
+
k += 1
|
|
114
|
+
value, j = s[j:k], k
|
|
115
|
+
out[key] = value
|
|
116
|
+
i = j
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_conninfo(conninfo: str) -> dict[str, str]:
|
|
121
|
+
conninfo = conninfo.strip()
|
|
122
|
+
if conninfo.startswith(("postgresql://", "postgres://")):
|
|
123
|
+
return _parse_url(conninfo)
|
|
124
|
+
return _parse_keywords(conninfo)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _default_host(port: int) -> str:
|
|
128
|
+
for directory in ("/var/run/postgresql", "/tmp"):
|
|
129
|
+
if os.path.exists(f"{directory}/.s.PGSQL.{port}"):
|
|
130
|
+
return directory
|
|
131
|
+
return "localhost"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_params(conninfo: str = "", **kwargs: object) -> ConnParams:
|
|
135
|
+
values = parse_conninfo(conninfo) if conninfo else {}
|
|
136
|
+
for key, value in kwargs.items():
|
|
137
|
+
if value is not None:
|
|
138
|
+
values[key] = str(value)
|
|
139
|
+
if "database" in values:
|
|
140
|
+
values.setdefault("dbname", values.pop("database"))
|
|
141
|
+
unknown = set(values) - _KNOWN
|
|
142
|
+
if unknown:
|
|
143
|
+
raise errors.ProgrammingError(f"invalid connection option {sorted(unknown)[0]!r}")
|
|
144
|
+
for key, env in _ENV.items():
|
|
145
|
+
if not values.get(key) and os.environ.get(env):
|
|
146
|
+
values[key] = os.environ[env]
|
|
147
|
+
|
|
148
|
+
user = values.get("user") or getpass.getuser()
|
|
149
|
+
host_text = values.get("host", "")
|
|
150
|
+
port_text = values.get("port", "")
|
|
151
|
+
ports = [p for p in port_text.split(",")] if port_text else []
|
|
152
|
+
try:
|
|
153
|
+
port_numbers = [int(p) if p else 5432 for p in ports] or [5432]
|
|
154
|
+
except ValueError:
|
|
155
|
+
raise errors.ProgrammingError(f"invalid port {port_text!r}") from None
|
|
156
|
+
host_list = host_text.split(",") if host_text else [_default_host(port_numbers[0])]
|
|
157
|
+
hosts: list[tuple[str, int]] = []
|
|
158
|
+
for index, host in enumerate(host_list):
|
|
159
|
+
port = port_numbers[index] if len(port_numbers) > 1 else port_numbers[0]
|
|
160
|
+
hosts.append((host or _default_host(port), port))
|
|
161
|
+
|
|
162
|
+
sslmode = values.get("sslmode") or "prefer"
|
|
163
|
+
if sslmode not in SSL_MODES:
|
|
164
|
+
raise errors.ProgrammingError(f"invalid sslmode value {sslmode!r}")
|
|
165
|
+
timeout_text = values.get("connect_timeout")
|
|
166
|
+
try:
|
|
167
|
+
timeout = float(timeout_text) if timeout_text else None
|
|
168
|
+
except ValueError:
|
|
169
|
+
raise errors.ProgrammingError(f"invalid connect_timeout {timeout_text!r}") from None
|
|
170
|
+
return ConnParams(
|
|
171
|
+
hosts=hosts,
|
|
172
|
+
user=user,
|
|
173
|
+
password=values.get("password") or None,
|
|
174
|
+
dbname=values.get("dbname") or user,
|
|
175
|
+
sslmode=sslmode,
|
|
176
|
+
sslrootcert=values.get("sslrootcert") or None,
|
|
177
|
+
sslcert=values.get("sslcert") or None,
|
|
178
|
+
sslkey=values.get("sslkey") or None,
|
|
179
|
+
connect_timeout=timeout if timeout and timeout > 0 else None,
|
|
180
|
+
application_name=values.get("application_name") or None,
|
|
181
|
+
options=values.get("options") or None,
|
|
182
|
+
)
|