rowproof 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.
rowproof/cli/spec.py ADDED
@@ -0,0 +1,99 @@
1
+ """Parsing for the SOURCE/TARGET connection+table grammar (spec §7):
2
+
3
+ postgres://user:pw@host:5432/db/public.orders
4
+
5
+ i.e. a normal DSN, plus one extra path segment naming `schema.table` (or
6
+ just `table`, schema defaults to "public"). This is intentionally the only
7
+ thing this module does — engine dispatch and the actual connection live in
8
+ connectors/.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from urllib.parse import urlsplit, urlunsplit
15
+
16
+ from rowproof.core.models import TableRef
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ParsedSource:
21
+ engine: str
22
+ connect_dsn: str
23
+ table_ref: TableRef
24
+
25
+
26
+ _ENGINE_DEFAULT_PORT = {"postgres": 5432, "postgresql": 5432}
27
+
28
+
29
+ def parse_source_spec(spec: str) -> ParsedSource:
30
+ parts = urlsplit(spec)
31
+ engine = parts.scheme
32
+ if not engine:
33
+ raise ValueError(
34
+ f"'{spec}' doesn't look like a connection string "
35
+ "(expected engine://user:pw@host:port/database/table)"
36
+ )
37
+
38
+ path_segments = [seg for seg in parts.path.split("/") if seg]
39
+ if len(path_segments) < 2:
40
+ raise ValueError(
41
+ f"'{spec}' is missing the table: expected "
42
+ "engine://user:pw@host:port/database/[schema.]table"
43
+ )
44
+ database = path_segments[0]
45
+ table_spec = "/".join(path_segments[1:])
46
+
47
+ if "." in table_spec:
48
+ schema, table = table_spec.split(".", 1)
49
+ else:
50
+ schema, table = None, table_spec
51
+
52
+ netloc = parts.netloc
53
+ connect_dsn = f"{parts.scheme}://{netloc}/{database}"
54
+
55
+ return ParsedSource(
56
+ engine=engine,
57
+ connect_dsn=connect_dsn,
58
+ table_ref=TableRef(engine=engine, database=database, schema=schema, table=table),
59
+ )
60
+
61
+
62
+ def redact_dsn(dsn: str) -> str:
63
+ """spec §8.3: "both DSNs with secrets redacted" — used by the HTML
64
+ report's "Reproduce" section (and anywhere else a DSN might be shown
65
+ to a human). Generated SQL never embeds a DSN, so this is the only
66
+ place redaction is needed.
67
+ """
68
+ parts = urlsplit(dsn)
69
+ if not parts.password:
70
+ return dsn
71
+ # `.password` returns the netloc's own (possibly percent-encoded) text
72
+ # verbatim, not unquoted -- so it can be matched back into `.netloc`
73
+ # directly with no re-encoding step.
74
+ redacted_netloc = parts.netloc.replace(f":{parts.password}@", ":***@")
75
+ return urlunsplit((parts.scheme, redacted_netloc, parts.path, parts.query, parts.fragment))
76
+
77
+
78
+ def resolve_source_spec(spec: str, connections: dict[str, str] | None) -> ParsedSource:
79
+ """spec §7: SOURCE/TARGET is either a full connection string, "or a
80
+ named connection from the config file: prod_pg/public.orders" — used
81
+ by `rowproof run` (and `cli.main.cmd_diff`, so `rowproof diff` can
82
+ reference a config's connections too, once one is loaded). A named
83
+ connection's own value is a bare DSN with no table (validated by
84
+ config.load_config); this just splices the two together and reuses
85
+ parse_source_spec's own DSN+table grammar rather than duplicating it.
86
+ """
87
+ if "://" in spec:
88
+ return parse_source_spec(spec)
89
+ if not connections:
90
+ raise ValueError(
91
+ f"'{spec}' doesn't look like a connection string and no named "
92
+ "connections were loaded (expected engine://... or name/[schema.]table)"
93
+ )
94
+ name, _, rest = spec.partition("/")
95
+ if name not in connections:
96
+ raise ValueError(f"unknown connection '{name}' (known: {', '.join(sorted(connections))})")
97
+ if not rest:
98
+ raise ValueError(f"'{spec}' is missing the table: expected {name}/[schema.]table")
99
+ return parse_source_spec(f"{connections[name]}/{rest}")
@@ -0,0 +1,167 @@
1
+ # YAML config loading/validation for `rowproof run config.yaml` (spec §7,
2
+ # §9 M1).
3
+ """Config file format (spec §7's `rowproof run CONFIG.yaml`):
4
+
5
+ connections:
6
+ prod_pg: postgres://user:${PROD_PG_PASSWORD}@host:5432/db
7
+ analytics_ch: clickhouse://user:${CH_PASSWORD}@host:8123/db
8
+
9
+ tables:
10
+ - source: prod_pg/public.orders
11
+ target: analytics_ch/orders
12
+ key: order_id
13
+ where: created_at > '2024-01-01'
14
+ - source: prod_pg/public.customers
15
+ target: analytics_ch/customers
16
+
17
+ A connection's value may also be `{dsn: ...}` for room to grow (e.g. a
18
+ future per-connection option) without breaking the plain-string form.
19
+ Every table entry accepts the same options `diff` does (key, columns,
20
+ exclude, where/where_source/where_target, algorithm, row_threshold,
21
+ max_diff_rows, trim, case_insensitive, float_precision, assume_tz,
22
+ column_map, fail_on) — `key`/`columns`/`exclude` accept either a YAML
23
+ list or a comma-separated string, matching the CLI flag's own grammar.
24
+
25
+ `${ENV_VAR}` is substituted everywhere in the raw file (spec §7:
26
+ "Secrets: accept ${ENV_VAR} in config and DSNs") before YAML parsing even
27
+ starts, so it works in a DSN, a `where` clause, or anywhere else — never
28
+ printed or logged: PostgresConnector's own query logging only ever sees
29
+ the substituted DSN inside a *DSN*, which _pgwire.py already never logs
30
+ (passwords go through PGPASSWORD, not the SQL/-c argument list).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import re
37
+ from dataclasses import dataclass, field
38
+
39
+ import yaml
40
+
41
+ from rowproof.core.errors import RowProofError
42
+ from rowproof.core.hashdiff import DEFAULT_MAX_DIFF_ROWS, DEFAULT_ROW_THRESHOLD
43
+
44
+ _ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
45
+
46
+
47
+ def _substitute_env(text: str) -> str:
48
+ def replace(m: re.Match) -> str:
49
+ name = m.group(1)
50
+ if name not in os.environ:
51
+ raise RowProofError(
52
+ f"config references ${{{name}}} but that environment variable is not set"
53
+ )
54
+ return os.environ[name]
55
+
56
+ return _ENV_VAR_RE.sub(replace, text)
57
+
58
+
59
+ def _as_list(value) -> list[str] | None:
60
+ if value is None:
61
+ return None
62
+ if isinstance(value, str):
63
+ return [v.strip() for v in value.split(",") if v.strip()]
64
+ if isinstance(value, list):
65
+ return [str(v) for v in value]
66
+ raise RowProofError(f"expected a list or comma-separated string, got {value!r}")
67
+
68
+
69
+ def _as_column_map(value) -> dict | None:
70
+ if value is None:
71
+ return None
72
+ if isinstance(value, dict):
73
+ return {str(k): str(v) for k, v in value.items()}
74
+ if isinstance(value, str):
75
+ result = {}
76
+ for pair in value.split(","):
77
+ pair = pair.strip()
78
+ if not pair:
79
+ continue
80
+ src, _, tgt = pair.partition(":")
81
+ result[src.strip()] = tgt.strip()
82
+ return result or None
83
+ raise RowProofError(f"column_map: expected a mapping or 'a:b,c:d' string, got {value!r}")
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class TableJob:
88
+ source: str
89
+ target: str
90
+ key: list[str] | None = None
91
+ columns: list[str] | None = None
92
+ exclude: list[str] | None = None
93
+ where: str | None = None
94
+ where_source: str | None = None
95
+ where_target: str | None = None
96
+ algorithm: str = "auto"
97
+ row_threshold: int = DEFAULT_ROW_THRESHOLD
98
+ max_diff_rows: int = DEFAULT_MAX_DIFF_ROWS
99
+ trim: bool = False
100
+ case_insensitive: bool = False
101
+ float_precision: int = 15
102
+ assume_tz: str = "UTC"
103
+ column_map: dict | None = None
104
+ fail_on: str = "any"
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Config:
109
+ connections: dict[str, str] = field(default_factory=dict)
110
+ tables: list[TableJob] = field(default_factory=list)
111
+
112
+
113
+ def _resolve_connection_value(name: str, value) -> str:
114
+ if isinstance(value, str):
115
+ return value
116
+ if isinstance(value, dict) and "dsn" in value:
117
+ return str(value["dsn"])
118
+ raise RowProofError(f"connections.{name}: expected a DSN string or a mapping with a 'dsn' key")
119
+
120
+
121
+ def load_config(path: str) -> Config:
122
+ try:
123
+ with open(path, encoding="utf-8") as f:
124
+ raw_text = f.read()
125
+ except OSError as e:
126
+ raise RowProofError(f"could not read config file '{path}': {e}") from e
127
+
128
+ raw_text = _substitute_env(raw_text)
129
+ try:
130
+ data = yaml.safe_load(raw_text) or {}
131
+ except yaml.YAMLError as e:
132
+ raise RowProofError(f"'{path}' is not valid YAML: {e}") from e
133
+ if not isinstance(data, dict):
134
+ raise RowProofError(f"'{path}' must be a YAML mapping with 'connections'/'tables' keys")
135
+
136
+ connections = {
137
+ name: _resolve_connection_value(name, value)
138
+ for name, value in (data.get("connections") or {}).items()
139
+ }
140
+
141
+ tables = []
142
+ for i, t in enumerate(data.get("tables") or []):
143
+ if not isinstance(t, dict) or "source" not in t or "target" not in t:
144
+ raise RowProofError(f"tables[{i}]: both 'source' and 'target' are required")
145
+ tables.append(
146
+ TableJob(
147
+ source=t["source"],
148
+ target=t["target"],
149
+ key=_as_list(t.get("key")),
150
+ columns=_as_list(t.get("columns")),
151
+ exclude=_as_list(t.get("exclude")),
152
+ where=t.get("where"),
153
+ where_source=t.get("where_source"),
154
+ where_target=t.get("where_target"),
155
+ algorithm=t.get("algorithm", "auto"),
156
+ row_threshold=t.get("row_threshold", DEFAULT_ROW_THRESHOLD),
157
+ max_diff_rows=t.get("max_diff_rows", DEFAULT_MAX_DIFF_ROWS),
158
+ trim=t.get("trim", False),
159
+ case_insensitive=t.get("case_insensitive", False),
160
+ float_precision=t.get("float_precision", 15),
161
+ assume_tz=t.get("assume_tz", "UTC"),
162
+ column_map=_as_column_map(t.get("column_map")),
163
+ fail_on=t.get("fail_on", "any"),
164
+ )
165
+ )
166
+
167
+ return Config(connections=connections, tables=tables)
File without changes
@@ -0,0 +1,160 @@
1
+ """ClickHouse wire access, via `clickhouse-connect` — the real spec §3
2
+ driver.
3
+
4
+ `ClickHouseConnector` in clickhouse.py calls only the functions below,
5
+ never clickhouse_connect directly. This module used to be a hand-rolled
6
+ stdlib `urllib` HTTP client (see git history / docs/DEV_ENVIRONMENT.md
7
+ for why); clickhouse-connect replaces that with the real driver, which
8
+ speaks the same HTTP interface but also handles response decoding,
9
+ per-engine type mapping, and connection re-use itself instead of this
10
+ module doing it by hand.
11
+
12
+ Two query paths, matching two different needs (mirrors _pgwire.py's own
13
+ split — see that module's docstring for the full performance rationale):
14
+
15
+ * `open_client()` / `run_query_on()` / `close_client()` — a genuinely
16
+ persistent client, opened once by `connect()` and reused for every
17
+ query for the lifetime of a diff (spec §5: "One connection per side.
18
+ No connection pooling in v1."). Reconnecting per query measured at
19
+ ~20ms of pure overhead against a local ClickHouse — negligible for one
20
+ query, ruinous across the thousands a large diff issues.
21
+ * `run_query()` / `check_connection()` — a one-shot query against a
22
+ fresh client, for stateless reachability probes and test-only admin
23
+ operations that have no diff-lifetime client to reuse.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from dataclasses import dataclass
30
+ from urllib.parse import unquote, urlsplit
31
+
32
+ import clickhouse_connect
33
+ from clickhouse_connect.driver.client import Client
34
+ from clickhouse_connect.driver.exceptions import ClickHouseError
35
+ from clickhouse_connect.driver.exceptions import OperationalError as ChOperationalError
36
+
37
+ # clickhouse-connect logs a warning (not an exception) when a DDL
38
+ # response's X-ClickHouse-Summary header isn't the JSON shape a SELECT
39
+ # response has (e.g. CREATE DATABASE) — harmless (the statement still
40
+ # succeeds), but noisy by default; quiet it to genuine errors only.
41
+ logging.getLogger("clickhouse_connect").setLevel(logging.ERROR)
42
+
43
+ # ClickHouse's AUTHENTICATION_FAILED error code (see
44
+ # https://github.com/ClickHouse/ClickHouse/blob/master/src/Common/ErrorCodes.cpp).
45
+ # Unlike Postgres, a bad password over ClickHouse's HTTP interface doesn't
46
+ # fail at the transport layer (no OperationalError) — the server answers
47
+ # with an ordinary-looking error response carrying this code, so it has to
48
+ # be recognised by code, not by exception type, to be treated as a
49
+ # connection failure rather than a query failure (spec §10: "Clean error
50
+ # for: bad credentials").
51
+ _AUTH_FAILED_CODE = 516
52
+
53
+
54
+ class ConnectionFailedError(Exception):
55
+ pass
56
+
57
+
58
+ class QueryFailedError(Exception):
59
+ def __init__(self, message: str, sql: str):
60
+ super().__init__(message)
61
+ self.sql = sql
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class ChDsn:
66
+ host: str
67
+ port: int
68
+ user: str
69
+ password: str | None
70
+ database: str
71
+
72
+
73
+ def parse_ch_dsn(dsn: str) -> ChDsn:
74
+ """Parse `clickhouse://user:pw@host:port/database`. Default port 8123
75
+ (ClickHouse's plain-HTTP port — what clickhouse-connect itself talks;
76
+ 8443 for HTTPS is out of scope for v1, same as Postgres's DSN handling
77
+ not covering `sslmode`)."""
78
+ parts = urlsplit(dsn)
79
+ if parts.scheme not in ("clickhouse", "ch"):
80
+ raise ValueError(f"not a clickhouse DSN: {dsn!r}")
81
+ database = parts.path.lstrip("/")
82
+ return ChDsn(
83
+ host=parts.hostname or "localhost",
84
+ port=parts.port or 8123,
85
+ user=unquote(parts.username) if parts.username else "default",
86
+ password=unquote(parts.password) if parts.password else None,
87
+ database=database or "default",
88
+ )
89
+
90
+
91
+ def _reraise_as_rowproof_error(e: Exception, sql: str) -> None:
92
+ if isinstance(e, ChOperationalError):
93
+ # A real transport-level failure: refused/unreachable host,
94
+ # connection reset, DNS failure.
95
+ raise ConnectionFailedError(str(e)) from e
96
+ if isinstance(e, ClickHouseError) and getattr(e, "code", None) == _AUTH_FAILED_CODE:
97
+ raise ConnectionFailedError(str(e)) from e
98
+ if isinstance(e, ClickHouseError):
99
+ raise QueryFailedError(str(e), sql) from e
100
+ raise e
101
+
102
+
103
+ def open_client(dsn: ChDsn, timeout: float | None = 10.0) -> Client:
104
+ """Open and return a single persistent client — the one this side of
105
+ a diff uses for every query until `close_client()`."""
106
+ try:
107
+ return clickhouse_connect.get_client(
108
+ host=dsn.host,
109
+ port=dsn.port,
110
+ username=dsn.user,
111
+ password=dsn.password or "",
112
+ database=dsn.database,
113
+ connect_timeout=max(1, int(timeout)) if timeout else 10,
114
+ )
115
+ except Exception as e: # noqa: BLE001 - re-raised as a typed rowproof error below
116
+ _reraise_as_rowproof_error(e, "<connect>")
117
+ raise # pragma: no cover - _reraise_as_rowproof_error always raises
118
+
119
+
120
+ def close_client(client: Client) -> None:
121
+ client.close()
122
+
123
+
124
+ def run_query_on(client: Client, sql: str, timeout: float | None = None) -> list[tuple]:
125
+ """Run `sql` on an already-open persistent client and return rows as
126
+ tuples. This is the hot path every per-segment query in a diff goes
127
+ through — no new client, no re-authentication. Never mutates `sql`
128
+ (no FORMAT clause appended, no query rewriting) — the exact text a
129
+ caller built is the exact text sent, matching spec §5's
130
+ `--verbose`-reproducibility promise.
131
+ """
132
+ try:
133
+ settings = {"max_execution_time": timeout} if timeout else None
134
+ result = client.query(sql, settings=settings)
135
+ return [tuple(row) for row in result.result_rows]
136
+ except Exception as e: # noqa: BLE001
137
+ _reraise_as_rowproof_error(e, sql)
138
+ raise # pragma: no cover
139
+
140
+
141
+ def check_connection(dsn: ChDsn) -> None:
142
+ try:
143
+ run_query(dsn, "SELECT 1")
144
+ except QueryFailedError as e:
145
+ raise ConnectionFailedError(str(e)) from e
146
+
147
+
148
+ def run_query(dsn: ChDsn, sql: str, timeout: float | None = 30.0) -> list[tuple]:
149
+ """Open a fresh client, run `sql`, return rows as tuples, close.
150
+
151
+ A deliberate one-shot path — for `check_connection`'s stateless
152
+ reachability probes and test-only admin operations, never for the
153
+ diff hot path (see module docstring; `ClickHouseConnector.query()`
154
+ uses `run_query_on()` on its one persistent client instead).
155
+ """
156
+ client = open_client(dsn, timeout)
157
+ try:
158
+ return run_query_on(client, sql, timeout)
159
+ finally:
160
+ client.close()
@@ -0,0 +1,165 @@
1
+ """Postgres wire access, via `psycopg` (v3) — the real spec §3 driver.
2
+
3
+ `PostgresConnector` in postgres.py calls only the functions below, never
4
+ psycopg directly, so the rest of the codebase is insulated from driver
5
+ specifics. This module used to shell out to the `psql` CLI binary (see
6
+ git history / docs/DEV_ENVIRONMENT.md for why); psycopg replaces that
7
+ with a real wire-protocol connection and hands back already-typed Python
8
+ values (int, Decimal, datetime, bool, ...) instead of text that has to be
9
+ guessed back into a type.
10
+
11
+ Two query paths, matching two different needs:
12
+
13
+ * `open_connection()` / `run_query_on()` / `close_connection()` — a
14
+ genuinely persistent connection, opened once by `connect()` and reused
15
+ for every query for the lifetime of a diff. This is what spec §5 means
16
+ by "One connection per side. No connection pooling in v1." and is
17
+ performance-critical: a 100M-row diff issues thousands of small
18
+ per-segment queries (spec §13's 100M-row/5-minute benchmark), and
19
+ reconnecting for each one measured at ~17ms of pure overhead against a
20
+ local Postgres — at a few thousand queries that alone would blow the
21
+ time budget several times over.
22
+ * `run_query()` / `check_connection()` — a one-shot, self-contained
23
+ query against a fresh connection, for the handful of call sites that
24
+ genuinely want that: a stateless reachability probe (`check_connection`,
25
+ used both by `connect()` below and by test fixtures polling "is the
26
+ server back up yet") and test-only admin operations (creating/dropping
27
+ whole scratch databases — see `tests/integration/conftest.py`'s
28
+ `exec_sql`), which have no diff-lifetime connection to reuse anyway.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass
34
+ from urllib.parse import unquote, urlsplit
35
+
36
+ import psycopg
37
+
38
+
39
+ class ConnectionFailedError(Exception):
40
+ pass
41
+
42
+
43
+ class QueryFailedError(Exception):
44
+ def __init__(self, message: str, sql: str):
45
+ super().__init__(message)
46
+ self.sql = sql
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class PgDsn:
51
+ host: str
52
+ port: int
53
+ user: str
54
+ password: str | None
55
+ database: str
56
+
57
+
58
+ def parse_pg_dsn(dsn: str) -> PgDsn:
59
+ """Parse `postgres://user:pw@host:port/database`.
60
+
61
+ Only the connection portion is handled here — a trailing
62
+ `/schema.table` (spec §7's SOURCE/TARGET grammar) is the CLI layer's
63
+ job to split off before calling connect().
64
+ """
65
+ parts = urlsplit(dsn)
66
+ if parts.scheme not in ("postgres", "postgresql"):
67
+ raise ValueError(f"not a postgres DSN: {dsn!r}")
68
+ database = parts.path.lstrip("/")
69
+ return PgDsn(
70
+ host=parts.hostname or "localhost",
71
+ port=parts.port or 5432,
72
+ user=unquote(parts.username) if parts.username else "",
73
+ password=unquote(parts.password) if parts.password else None,
74
+ database=database,
75
+ )
76
+
77
+
78
+ def _connect_kwargs(dsn: PgDsn, connect_timeout: float | None) -> dict:
79
+ kwargs = {
80
+ "host": dsn.host,
81
+ "port": dsn.port,
82
+ "user": dsn.user,
83
+ "dbname": dsn.database,
84
+ "autocommit": True,
85
+ }
86
+ if dsn.password is not None:
87
+ kwargs["password"] = dsn.password
88
+ if connect_timeout:
89
+ # libpq's connect_timeout is whole seconds; round up so a small
90
+ # fractional timeout doesn't collapse to 0 (which libpq treats as
91
+ # "no timeout" — the opposite of what a caller asking for a short
92
+ # timeout means).
93
+ kwargs["connect_timeout"] = max(1, int(connect_timeout + 0.999))
94
+ return kwargs
95
+
96
+
97
+ def _classify(e: Exception, sql: str, timeout: float | None) -> None:
98
+ """Re-raise a psycopg exception as the rowproof-typed error the rest
99
+ of the codebase expects. Shared by both the persistent-connection and
100
+ one-shot query paths so the two stay classified identically."""
101
+ if isinstance(e, psycopg.errors.QueryCanceled):
102
+ raise QueryFailedError(f"query timed out after {timeout}s", sql) from e
103
+ if isinstance(e, psycopg.OperationalError):
104
+ # Every libpq-level failure (refused/reset connection, auth
105
+ # failure, unreachable host, dropped mid-query) surfaces as
106
+ # OperationalError in psycopg — see spec §10's error-path list.
107
+ raise ConnectionFailedError(str(e)) from e
108
+ if isinstance(e, psycopg.Error):
109
+ raise QueryFailedError(str(e), sql) from e
110
+ raise e
111
+
112
+
113
+ def open_connection(dsn: PgDsn, timeout: float | None = 10.0) -> psycopg.Connection:
114
+ """Open and return a single persistent connection — the one this side
115
+ of a diff uses for every query until `close_connection()`."""
116
+ try:
117
+ return psycopg.connect(**_connect_kwargs(dsn, timeout))
118
+ except psycopg.Error as e:
119
+ _classify(e, "<connect>", timeout)
120
+ raise # pragma: no cover - _classify always raises for psycopg.Error
121
+
122
+
123
+ def close_connection(conn: psycopg.Connection) -> None:
124
+ conn.close()
125
+
126
+
127
+ def run_query_on(conn: psycopg.Connection, sql: str, timeout: float | None = None) -> list[tuple]:
128
+ """Run `sql` on an already-open persistent connection and return rows
129
+ as tuples. This is the hot path every per-segment query in a diff
130
+ goes through — no new connection, no re-authentication."""
131
+ try:
132
+ if timeout:
133
+ # Server-side safety net so a hung query can't block forever.
134
+ conn.execute(f"SET statement_timeout = {int(timeout * 1000)}")
135
+ with conn.cursor() as cur:
136
+ cur.execute(sql)
137
+ if cur.description is None:
138
+ return []
139
+ return [tuple(row) for row in cur.fetchall()]
140
+ except psycopg.Error as e:
141
+ _classify(e, sql, timeout)
142
+ raise # pragma: no cover
143
+
144
+
145
+ def check_connection(dsn: PgDsn, timeout: float = 10.0) -> None:
146
+ try:
147
+ run_query(dsn, "SELECT 1", timeout=timeout)
148
+ except QueryFailedError as e:
149
+ raise ConnectionFailedError(str(e)) from e
150
+
151
+
152
+ def run_query(dsn: PgDsn, sql: str, timeout: float | None = 60.0) -> list[tuple]:
153
+ """Open a fresh connection, run `sql`, return rows as tuples, close.
154
+
155
+ A deliberate one-shot path — for `check_connection`'s stateless
156
+ reachability probes and test-only admin operations, never for the
157
+ diff hot path (see module docstring; `PostgresConnector.query()` uses
158
+ `run_query_on()` on its one persistent connection instead).
159
+ """
160
+ try:
161
+ with psycopg.connect(**_connect_kwargs(dsn, timeout)) as conn:
162
+ return run_query_on(conn, sql, timeout)
163
+ except psycopg.Error as e:
164
+ _classify(e, sql, timeout)
165
+ raise # pragma: no cover