feather-cdc-postgres 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.
@@ -0,0 +1,6 @@
1
+ """feather_cdc_postgres — declarative PostgreSQL → MotherDuck CDC runtime (#3079).
2
+
3
+ One code artifact, one isolated service instance per registration (`<source_id>.yml`). The
4
+ registrations are the DEPLOYER's, not this package's. Mechanism: slot-based logical replication
5
+ (pgoutput) drained cron-shaped into a bronze database, source namespaces preserved.
6
+ """
@@ -0,0 +1,9 @@
1
+ """`python -m feather_cdc_postgres` entry point."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -0,0 +1,44 @@
1
+ """Discovery adapters: source-specific POLICY that narrows the generic set of relations.
2
+
3
+ An adapter may only answer "which of the source's relations are in scope, and what does the
4
+ source call them" — it never supplies types, keys, or load behaviour. That keeps the CDC
5
+ mechanism single-implementation (#3079 D4); a fork of the mechanism per source is the failure
6
+ this design exists to prevent.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Callable, Dict, List, NamedTuple
11
+
12
+
13
+ # How an adapter came to offer a relation. `user_created` = the source's own users declared
14
+ # it (always application data); `named_system` = one of the source's platform tables the
15
+ # manifest names explicitly. None = the adapter applied no such distinction.
16
+ USER_CREATED = "user_created"
17
+ NAMED_SYSTEM = "named_system"
18
+
19
+
20
+ class AdapterRelation(NamedTuple):
21
+ """(schema, table, module) — plus a description the source itself owns, when it has one,
22
+ and the `origin` the adapter selected it under (see USER_CREATED / NAMED_SYSTEM)."""
23
+
24
+ schema: str
25
+ table: str
26
+ module: str | None = None
27
+ description: str | None = None
28
+ origin: str | None = None
29
+
30
+
31
+ AdapterFn = Callable[[str, Dict[str, Any]], List[AdapterRelation]]
32
+
33
+
34
+ def get(name: str) -> AdapterFn:
35
+ """Resolve an adapter by manifest name. Unknown names fail loudly at validate time."""
36
+ if name == "featherbase":
37
+ from .featherbase import select as featherbase_select
38
+
39
+ return featherbase_select
40
+ raise KeyError(name)
41
+
42
+
43
+ def known() -> tuple:
44
+ return ("featherbase",)
@@ -0,0 +1,126 @@
1
+ """Featherbase catalog adapter — `table_def` decides which relations exist (#2209, #3079 D4/D-13).
2
+
3
+ Constraints (each one is a live behaviour of Featherbase, not a preference):
4
+ - platform physical name = lower(Table name) with spaces → underscores
5
+ ('Access Log' → access_log) in the catalog schema;
6
+ - current app Tables name their physical schema/relation in the catalog;
7
+ - only kind in (table, sub_table) — settings Tables are EAV rows, not relations;
8
+ - data_source IS NULL — a row with one is a reflection of an external source, not ours;
9
+ - a declared Table with no physical relation yet is SKIPPED, never an error;
10
+ - `options.system_tables` given ⇒ two-way rule: `table_def."system" = true` rows only when
11
+ named there, `false` rows (user-created) always; absent ⇒ everything, catalog included.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Dict, List
16
+
17
+ import psycopg
18
+
19
+ from . import NAMED_SYSTEM, USER_CREATED, AdapterRelation
20
+
21
+ # The catalog itself. Descriptions match the retired scripts/generate_featherbase_sources.py, which this
22
+ # adapter supersedes — the generated dbt file must not churn on the switch.
23
+ CATALOG_TABLES: Dict[str, str] = {
24
+ "table_def": "Featherbase catalog — one row per declared Table (the generation input for this file).",
25
+ "column_def": "Featherbase catalog — one row per declared Column.",
26
+ }
27
+
28
+
29
+ def select(dsn: str, options: Dict[str, Any] | None = None) -> List[AdapterRelation]:
30
+ """Relations Featherbase's own catalog declares. `options.modules` is an allow-list of
31
+ module names; null/absent means every module. `options.schema` defaults to public.
32
+ `options.system_tables` (list of physical names) switches on the two-way rule: a platform
33
+ table (`"system" = true`) is offered ONLY when named there — `table_def`/`column_def`
34
+ included — and a user-created table (`"system" = false`) is always offered."""
35
+ options = options or {}
36
+ schema = str(options.get("schema") or "public")
37
+ raw_modules = options.get("modules")
38
+ modules = None if raw_modules is None else {str(m) for m in raw_modules}
39
+ raw_system = options.get("system_tables")
40
+ system_tables = None if raw_system is None else {str(t) for t in raw_system}
41
+
42
+ with psycopg.connect(dsn, connect_timeout=10) as conn:
43
+ conn.read_only = True
44
+ cols = {
45
+ r[0]
46
+ for r in conn.execute(
47
+ "select column_name from information_schema.columns "
48
+ "where table_schema = %s and table_name = 'table_def'",
49
+ (schema,),
50
+ ).fetchall()
51
+ }
52
+ if not cols:
53
+ raise RuntimeError(f"{schema}.table_def not found — is this a Featherbase database?")
54
+ if system_tables is not None and "system" not in cols:
55
+ # Without the flag every row would read as user-created and the allow-list would
56
+ # admit every platform table — the opposite of what the option promises.
57
+ raise RuntimeError(
58
+ f"{schema}.table_def has no \"system\" column — options.system_tables needs it"
59
+ )
60
+ physical_cols = {"physical_schema", "physical_relation"}
61
+ if physical_cols & cols and not physical_cols <= cols:
62
+ raise RuntimeError(
63
+ f"{schema}.table_def needs both physical_schema and physical_relation"
64
+ )
65
+ has_physical = physical_cols <= cols
66
+ desc = '"description"' if "description" in cols else "null"
67
+ system = '"system"' if "system" in cols else "false"
68
+ physical_schema = "physical_schema" if has_physical else "null"
69
+ physical_relation = "physical_relation" if has_physical else "null"
70
+ rows = conn.execute(
71
+ f'select "name", module, data_source, kind, {desc}, {system}, '
72
+ f'{physical_schema}, {physical_relation} from "{schema}".table_def'
73
+ ).fetchall()
74
+ physical = {
75
+ (r[0], r[1])
76
+ for r in conn.execute(
77
+ "select schemaname, tablename from pg_tables"
78
+ ).fetchall()
79
+ }
80
+
81
+ out: Dict[tuple[str, str], AdapterRelation] = {}
82
+ for name, module, data_source, kind, description, is_system, physical_schema, physical_relation in rows:
83
+ derived_table = str(name).lower().replace(" ", "_")
84
+ if kind not in ("table", "sub_table"):
85
+ continue
86
+ if data_source is not None:
87
+ continue
88
+ if derived_table in CATALOG_TABLES:
89
+ continue # the catalog's own rows are handled below
90
+ if modules is not None and (module or "") not in modules:
91
+ continue
92
+ relation_schema = schema
93
+ relation_table = derived_table
94
+ if has_physical and not bool(is_system):
95
+ if physical_schema and physical_relation:
96
+ relation_schema = str(physical_schema)
97
+ relation_table = str(physical_relation)
98
+ elif "." in str(name):
99
+ # #4113: Featherbase's tableRelation() — a qualified (app-owned) Table is never
100
+ # located by its spelling; a plain one lives at the platform default above.
101
+ raise RuntimeError(
102
+ f"{schema}.table_def row {name!r} has no "
103
+ "physical_schema/physical_relation"
104
+ )
105
+ if (relation_schema, relation_table) not in physical:
106
+ continue # declared but not materialised yet
107
+ origin = None
108
+ if system_tables is not None:
109
+ if bool(is_system) and relation_table not in system_tables:
110
+ continue
111
+ origin = NAMED_SYSTEM if bool(is_system) else USER_CREATED
112
+ out[(relation_schema, relation_table)] = AdapterRelation(
113
+ relation_schema, relation_table, module, description or None, origin
114
+ )
115
+ for tbl, description in CATALOG_TABLES.items():
116
+ if (schema, tbl) not in physical:
117
+ continue
118
+ if system_tables is None:
119
+ out[(schema, tbl)] = AdapterRelation(
120
+ schema, tbl, None, description
121
+ ) # unconditional (legacy)
122
+ elif tbl in system_tables:
123
+ out[(schema, tbl)] = AdapterRelation(
124
+ schema, tbl, None, description, NAMED_SYSTEM
125
+ )
126
+ return [out[k] for k in sorted(out)]
@@ -0,0 +1,247 @@
1
+ """`python -m feather_cdc_postgres <command> <manifest>` (#3079 D1).
2
+
3
+ Constraints:
4
+ - `validate` and `plan` MUTATE NOTHING — they open read-only connections and print. Anything
5
+ that would change either database belongs to `run`.
6
+ - no environment VALUE is ever printed; only the NAME of the variable is echoed.
7
+ - `run`/`status`/`resnapshot` import the runtime lazily, so the compiler half stays usable
8
+ (and testable) without dlt credentials or a destination.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import pathlib
16
+ import sys
17
+ from typing import Dict, List, Optional, Tuple
18
+
19
+ from . import discovery, generate_dbt, manifest as mf
20
+ from .adapters import AdapterRelation, get as get_adapter, known as known_adapters
21
+ from .selectors import CompiledPlan, compile as compile_selection
22
+
23
+ def _env(name: str) -> Optional[str]:
24
+ val = os.environ.get(name)
25
+ return val or None
26
+
27
+
28
+ def _resolve(m: mf.Manifest) -> Tuple[Optional[str], List[mf.Problem]]:
29
+ """The source DSN, or a problem naming the variable that is unset — never its value."""
30
+ dsn = _env(m.source.dsn_env)
31
+ if not dsn:
32
+ return None, [mf.Problem("env_not_set", "error", f"environment variable {m.source.dsn_env} is not set")]
33
+ return dsn, []
34
+
35
+
36
+ def _adapter_relations(m: mf.Manifest, dsn: str) -> Tuple[List[AdapterRelation], List[mf.Problem]]:
37
+ if m.source.discovery is None:
38
+ return [], []
39
+ try:
40
+ fn = get_adapter(m.source.discovery.adapter)
41
+ except KeyError:
42
+ return [], [
43
+ mf.Problem(
44
+ "adapter_unknown",
45
+ "error",
46
+ f"discovery adapter {m.source.discovery.adapter!r} is unknown (known: {', '.join(known_adapters())})",
47
+ )
48
+ ]
49
+ return list(fn(dsn, m.source.discovery.options)), []
50
+
51
+
52
+ def _compile(m: mf.Manifest, *, with_publication: bool) -> Tuple[Optional[CompiledPlan], List[mf.Problem], Dict]:
53
+ problems = list(mf.validate_static(m))
54
+ dsn, dsn_problems = _resolve(m)
55
+ problems += dsn_problems
56
+ if dsn is None:
57
+ return None, problems, {}
58
+ relations = discovery.discover(dsn)
59
+ adapter_rels, adapter_problems = _adapter_relations(m, dsn)
60
+ problems += adapter_problems
61
+ published = discovery.publication_relations(dsn, m.replication.publication) if with_publication else None
62
+ plan = compile_selection(m, relations, adapter_rels, published)
63
+ extra: Dict = {}
64
+ if with_publication:
65
+ extra["publication_exists"] = published is not None
66
+ extra["slot"] = discovery.slot_state(dsn, m.replication.slot)
67
+ return plan, problems + list(plan.problems), extra
68
+
69
+
70
+ def _emit(payload: Dict, lines: List[str], as_json: bool) -> None:
71
+ text = json.dumps(payload, indent=2, default=str) if as_json else "\n".join(lines)
72
+ print(mf.redact(text))
73
+
74
+
75
+ def _problem_lines(problems: List[mf.Problem]) -> List[str]:
76
+ if not problems:
77
+ return ["problems: none"]
78
+ return ["problems:"] + [f" {p}" for p in problems]
79
+
80
+
81
+ def _relation_lines(plan: CompiledPlan) -> List[str]:
82
+ width = max((len(r.qualified) for r in plan.relations), default=10)
83
+ out = [f"relations ({len(plan.relations)}):"]
84
+ for r in plan.relations:
85
+ key = ",".join(r.key) or "-"
86
+ idx = f" via {r.key_index}" if r.key_index else ""
87
+ origin = f" origin={r.origin}" if r.origin else ""
88
+ # The column projection (#4346): these never leave the CDC process.
89
+ deny = f" deny={','.join(r.deny_columns)}" if r.deny_columns else ""
90
+ out.append(
91
+ f" {r.qualified:<{width}} -> {r.target:<{width}} {r.key_policy:<12} key={key}{idx} "
92
+ f"class={r.table_class}{origin}{deny}"
93
+ )
94
+ return out
95
+
96
+
97
+ def cmd_validate(args) -> int:
98
+ m = mf.load(args.manifest)
99
+ plan, problems, _ = _compile(m, with_publication=False)
100
+ lines = [f"manifest: {args.manifest}", f"source_id: {m.source_id}", f"dsn from: ${m.source.dsn_env}"]
101
+ payload: Dict = {"source_id": m.source_id, "problems": [p.__dict__ for p in problems]}
102
+ if plan is not None:
103
+ lines += _relation_lines(plan) + [f"selection_hash: {plan.selection_hash}"]
104
+ payload["relations"] = [r.__dict__ for r in plan.relations]
105
+ payload["selection_hash"] = plan.selection_hash
106
+ lines += _problem_lines(problems)
107
+ _emit(payload, lines, args.json)
108
+ return 1 if mf.errors(problems) else 0
109
+
110
+
111
+ def cmd_plan(args) -> int:
112
+ m = mf.load(args.manifest)
113
+ plan, problems, extra = _compile(m, with_publication=True)
114
+ lines = [f"manifest: {args.manifest}", f"source_id: {m.source_id}", "PLAN — nothing is mutated by this command"]
115
+ payload: Dict = {"source_id": m.source_id, "problems": [p.__dict__ for p in problems], **extra}
116
+ if plan is not None:
117
+ lines += _relation_lines(plan)
118
+ lines.append(f"publication {m.replication.publication}: " + ("exists" if extra.get("publication_exists") else "MISSING — would be created"))
119
+ lines.append(f" add ({len(plan.publication_add)}): " + (", ".join(f"{s}.{t}" for s, t in plan.publication_add) or "-"))
120
+ lines.append(f" drop ({len(plan.publication_drop)}): " + (", ".join(f"{s}.{t}" for s, t in plan.publication_drop) or "-"))
121
+ lines.append(
122
+ f"replica identity ALTERs ({len(plan.replica_identity_alters)}): "
123
+ + (", ".join(f"{s}.{t}" for s, t in plan.replica_identity_alters) or "-")
124
+ )
125
+ lines.append("denied hits: " + (", ".join(plan.denied_hits) or "-"))
126
+ lines.append(f"selection_hash: {plan.selection_hash}")
127
+ slot = extra.get("slot") or {}
128
+ lines.append(
129
+ f"slot {m.replication.slot}: "
130
+ + ("absent — would be created" if not slot.get("exists") else
131
+ f"wal_status={slot.get('wal_status')} lag_bytes={slot.get('lag_bytes')} active={slot.get('active')}")
132
+ + f" · server max_slot_wal_keep_size={slot.get('max_slot_wal_keep_size_bytes') or 'unlimited'}"
133
+ )
134
+ payload.update(
135
+ relations=[r.__dict__ for r in plan.relations],
136
+ publication_add=plan.publication_add,
137
+ publication_drop=plan.publication_drop,
138
+ replica_identity_alters=plan.replica_identity_alters,
139
+ denied_hits=plan.denied_hits,
140
+ selection_hash=plan.selection_hash,
141
+ )
142
+ lines += _problem_lines(problems)
143
+ _emit(payload, lines, args.json)
144
+ return 1 if mf.errors(problems) else 0
145
+
146
+
147
+ def cmd_generate_dbt(args) -> int:
148
+ m = mf.load(args.manifest)
149
+ plan, problems, _ = _compile(m, with_publication=False)
150
+ if plan is None or mf.errors(problems):
151
+ _emit({"problems": [p.__dict__ for p in problems]}, _problem_lines(problems), args.json)
152
+ return 1
153
+ out = pathlib.Path(args.out)
154
+ generate_dbt.write(m, plan, out)
155
+ _emit(
156
+ {"out": str(out), "tables": len(plan.relations)},
157
+ [f"wrote {out} — {len(plan.relations)} tables + _dlt_loads"],
158
+ args.json,
159
+ )
160
+ return 0
161
+
162
+
163
+ def _runner():
164
+ """The runtime half (#3079 Phase 2). Contract expected here:
165
+
166
+ runner.run(manifest: Manifest, *, dev: bool, manifest_path: str) -> int
167
+ runner.status(manifest: Manifest) -> int
168
+ runner.resnapshot(manifest: Manifest, *, tables, yes: bool, dev: bool) -> int
169
+ """
170
+ from . import runner # noqa: PLC0415 # TODO(runner): owned by the runtime half
171
+
172
+ return runner
173
+
174
+
175
+ def cmd_run(args) -> int:
176
+ return int(_runner().run(mf.load(args.manifest), dev=args.dev, manifest_path=args.manifest) or 0)
177
+
178
+
179
+ def cmd_status(args) -> int:
180
+ return int(_runner().status(mf.load(args.manifest)) or 0)
181
+
182
+
183
+ def cmd_resnapshot(args) -> int:
184
+ return int(_runner().resnapshot(mf.load(args.manifest), tables=args.table or None,
185
+ yes=args.yes, dev=args.dev) or 0)
186
+
187
+
188
+ def build_parser() -> argparse.ArgumentParser:
189
+ p = argparse.ArgumentParser(prog="feather_cdc_postgres", description="Declarative PostgreSQL → MotherDuck CDC runtime")
190
+ p.add_argument("--json", action="store_true", help="machine-readable output")
191
+ sub = p.add_subparsers(dest="command", required=True)
192
+
193
+ for name, fn, helptext in (
194
+ ("validate", cmd_validate, "static + read-only checks; prints the compiled relation set"),
195
+ ("plan", cmd_plan, "validate + every change a run would make; mutates nothing"),
196
+ ):
197
+ s = sub.add_parser(name, help=helptext)
198
+ s.add_argument("manifest")
199
+ s.set_defaults(func=fn)
200
+
201
+ s = sub.add_parser("generate-dbt", help="write dbt source declarations for this registration")
202
+ s.add_argument("manifest")
203
+ # REQUIRED, and it has no default on purpose: the default used to be
204
+ # `<repo root>/dbt_runner/dbt/models/<source_id>/...`, resolved four parents up from this
205
+ # file. That is one warehouse's directory layout, and a runtime installed from a wheel sits
206
+ # in site-packages, where four parents up is somebody's virtualenv. The caller owns the path.
207
+ s.add_argument("--out", required=True, help="output path for the generated sources YAML")
208
+ s.set_defaults(func=cmd_generate_dbt)
209
+
210
+ s = sub.add_parser("run", help="reconcile, snapshot and drain one tick")
211
+ s.add_argument("manifest")
212
+ s.add_argument("--dev", action="store_true", help="local destination, no control-plane writes")
213
+ s.set_defaults(func=cmd_run)
214
+
215
+ s = sub.add_parser("status", help="lifecycle, checkpoint and slot health")
216
+ s.add_argument("manifest")
217
+ s.set_defaults(func=cmd_status)
218
+
219
+ s = sub.add_parser("resnapshot", help="re-baseline selected tables")
220
+ s.add_argument("manifest")
221
+ # `--tables` is the spelling the drift remedy text prints; both names, one destination.
222
+ s.add_argument("--table", "--tables", dest="table", action="append",
223
+ help="schema.table (repeatable); default every relation")
224
+ s.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
225
+ s.add_argument("--dev", action="store_true", help="local destination, no control-plane writes")
226
+ s.set_defaults(func=cmd_resnapshot)
227
+ return p
228
+
229
+
230
+ def main(argv: Optional[List[str]] = None) -> int:
231
+ args = build_parser().parse_args(argv)
232
+ try:
233
+ return args.func(args)
234
+ except mf.ManifestError as exc:
235
+ print(f"[error] {mf.redact(str(exc))}", file=sys.stderr)
236
+ return 2
237
+ except Exception as exc: # noqa: BLE001
238
+ # Anything else — an unreachable source, a bad adapter, a duckdb error. A raw traceback
239
+ # would print psycopg's `connection to server at ...` line (and dlt's config exceptions
240
+ # echo the value they resolved) straight into Railway's log; redact() is the only thing
241
+ # standing between that and a DSN in a log line. SystemExit/KeyboardInterrupt are not
242
+ # Exception, so `sys.exit("FATAL: …")` from the runtime still propagates untouched.
243
+ message = mf.redact(f"{type(exc).__name__}: {exc}")
244
+ print(f"[error] {message}", file=sys.stderr)
245
+ if getattr(args, "json", False):
246
+ print(json.dumps({"error": type(exc).__name__, "message": message}, indent=2))
247
+ return 1