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/__init__.py +1 -0
- rowproof/cli/__init__.py +0 -0
- rowproof/cli/main.py +548 -0
- rowproof/cli/render.py +132 -0
- rowproof/cli/spec.py +99 -0
- rowproof/config/__init__.py +167 -0
- rowproof/connectors/__init__.py +0 -0
- rowproof/connectors/_chwire.py +160 -0
- rowproof/connectors/_pgwire.py +165 -0
- rowproof/connectors/_sfwire.py +157 -0
- rowproof/connectors/base.py +120 -0
- rowproof/connectors/clickhouse.py +580 -0
- rowproof/connectors/postgres.py +470 -0
- rowproof/connectors/snowflake.py +477 -0
- rowproof/core/__init__.py +0 -0
- rowproof/core/column_matching.py +217 -0
- rowproof/core/errors.py +37 -0
- rowproof/core/hashdiff.py +933 -0
- rowproof/core/joindiff.py +304 -0
- rowproof/core/models.py +192 -0
- rowproof/core/normalisation.py +119 -0
- rowproof/core/segmentation.py +159 -0
- rowproof/report/__init__.py +6 -0
- rowproof/report/html.py +167 -0
- rowproof-0.1.0.dist-info/METADATA +172 -0
- rowproof-0.1.0.dist-info/RECORD +30 -0
- rowproof-0.1.0.dist-info/WHEEL +5 -0
- rowproof-0.1.0.dist-info/entry_points.txt +2 -0
- rowproof-0.1.0.dist-info/licenses/LICENSE +201 -0
- rowproof-0.1.0.dist-info/top_level.txt +1 -0
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
|