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/_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
+ )