adbc-replicate 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,3 @@
1
+ """adbc-replicate: config-driven ADBC-to-ADBC replication, by GizmoData."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,112 @@
1
+ """Streaming wrappers around a RecordBatchReader. Each one keeps the stream streamed: at most
2
+ one (coalesced) batch is held at a time, so client memory doesn't grow with table size."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable
7
+
8
+ import pyarrow as pa
9
+ import pyarrow.compute as pc
10
+
11
+
12
+ def _storage_type(data_type: pa.DataType) -> pa.DataType:
13
+ """Strip Arrow extension types (arrow.json, arrow.opaque, ...) down to their storage type,
14
+ recursively, and widen second/millisecond timestamps. Targets ingest the physical types;
15
+ the extension metadata is source-specific."""
16
+ if isinstance(data_type, pa.BaseExtensionType):
17
+ return _storage_type(data_type=data_type.storage_type)
18
+ if pa.types.is_list(data_type):
19
+ return pa.list_(_storage_type(data_type=data_type.value_type))
20
+ if pa.types.is_large_list(data_type):
21
+ return pa.large_list(_storage_type(data_type=data_type.value_type))
22
+ if pa.types.is_struct(data_type):
23
+ return pa.struct([f.with_type(_storage_type(data_type=f.type)) for f in data_type])
24
+ if pa.types.is_map(data_type):
25
+ return pa.map_(_storage_type(data_type=data_type.key_type), _storage_type(data_type=data_type.item_type))
26
+ if pa.types.is_timestamp(data_type) and data_type.unit in ("s", "ms"):
27
+ # Widen to microseconds (lossless): DuckDB's native TIMESTAMP, rather than
28
+ # TIMESTAMP_S / TIMESTAMP_MS, which some BI tools and drivers handle poorly.
29
+ return pa.timestamp(unit="us", tz=data_type.tz)
30
+ return data_type
31
+
32
+
33
+ def _strip_array(array: pa.Array, target: pa.DataType) -> pa.Array:
34
+ if isinstance(array, pa.ExtensionArray):
35
+ array = array.storage
36
+ if array.type.equals(target):
37
+ return array
38
+ return array.cast(target_type=target)
39
+
40
+
41
+ def transform(reader: pa.RecordBatchReader, names: list[str],
42
+ casts: dict[int, pa.DataType] | None = None) -> pa.RecordBatchReader:
43
+ """Rename every column to `names` (zero-copy), strip extension types, and apply the
44
+ per-column `casts` ({column index: type}; a strict Arrow cast, so precision loss raises).
45
+
46
+ An empty source yields a single zero-row batch, because a Flight DoPut needs at least one
47
+ message to carry the schema.
48
+ """
49
+ casts = casts or {}
50
+ fields = []
51
+ for i, f in enumerate(reader.schema):
52
+ data_type = casts.get(i) or _storage_type(data_type=f.type)
53
+ fields.append(pa.field(names[i], data_type, nullable=True))
54
+ schema = pa.schema(fields)
55
+
56
+ def convert(batch: pa.RecordBatch) -> pa.RecordBatch:
57
+ arrays = []
58
+ for i, array in enumerate(batch.columns):
59
+ target = schema.field(i).type
60
+ if isinstance(array, pa.ExtensionArray):
61
+ array = array.storage
62
+ if not array.type.equals(target):
63
+ array = pc.cast(array, target_type=target) if i in casts else _strip_array(array=array, target=target)
64
+ arrays.append(array)
65
+ return pa.RecordBatch.from_arrays(arrays=arrays, schema=schema)
66
+
67
+ def batches():
68
+ yielded = False
69
+ for batch in reader:
70
+ yielded = True
71
+ yield convert(batch=batch)
72
+ if not yielded:
73
+ yield pa.RecordBatch.from_arrays(arrays=[pa.array([], type=f.type) for f in schema], schema=schema)
74
+
75
+ return pa.RecordBatchReader.from_batches(schema=schema, batches=batches())
76
+
77
+
78
+ def coalesce(reader: pa.RecordBatchReader, max_rows: int, max_bytes: int) -> pa.RecordBatchReader:
79
+ """Combine consecutive small batches into batches of up to `max_rows` rows and `max_bytes`
80
+ Arrow buffer bytes. A pending group is sent before a batch that would push it over either
81
+ cap; a single source batch that is already bigger passes through unchanged."""
82
+ schema = reader.schema
83
+
84
+ def combine(pending: list[pa.RecordBatch]) -> pa.RecordBatch:
85
+ if len(pending) == 1:
86
+ return pending[0]
87
+ return pa.Table.from_batches(batches=pending, schema=schema).combine_chunks().to_batches()[0]
88
+
89
+ def batches():
90
+ pending: list[pa.RecordBatch] = []
91
+ rows = size = 0
92
+ for batch in reader:
93
+ if pending and (rows + batch.num_rows > max_rows or size + batch.nbytes > max_bytes):
94
+ yield combine(pending=pending)
95
+ pending, rows, size = [], 0, 0
96
+ pending.append(batch)
97
+ rows += batch.num_rows
98
+ size += batch.nbytes
99
+ if pending:
100
+ yield combine(pending=pending)
101
+
102
+ return pa.RecordBatchReader.from_batches(schema=schema, batches=batches())
103
+
104
+
105
+ def counting(reader: pa.RecordBatchReader, on_batch: Callable[[int], None]) -> pa.RecordBatchReader:
106
+ """Call `on_batch(rows)` for every batch that passes through (for progress reporting)."""
107
+ def batches():
108
+ for batch in reader:
109
+ on_batch(batch.num_rows)
110
+ yield batch
111
+
112
+ return pa.RecordBatchReader.from_batches(schema=reader.schema, batches=batches())
adbc_replicate/cli.py ADDED
@@ -0,0 +1,302 @@
1
+ """The adbc-replicate command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import time
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from . import __version__
14
+ from .config import ConfigError, json_schema, load_config
15
+ from .engine import EXIT_CONFIG, EXIT_FAILED, EXIT_OK, Replicator, RunOptions
16
+ from .events import Reporter, fmt_seconds
17
+ from .planner import PlanError
18
+ from .views import ViewTranslationError, create_view_sql, load_override, translate_view
19
+
20
+ CONFIG_OPTION = click.option("-c", "--config", "config_path", required=True, type=click.Path(dir_okay=False),
21
+ envvar="ADBC_REPLICATE_CONFIG", help="Replication config (YAML or JSON).")
22
+ ENV_FILE_OPTION = click.option("--env-file", type=click.Path(dir_okay=False), envvar="DOTENV_PATH",
23
+ help="Load environment variables from this file first (existing variables win).")
24
+
25
+
26
+ def _load(config_path: str, env_file: str | None):
27
+ if env_file:
28
+ from dotenv import load_dotenv
29
+ if not Path(env_file).is_file():
30
+ raise ConfigError(f"env file {env_file} not found")
31
+ load_dotenv(dotenv_path=env_file, override=False)
32
+ return load_config(path=config_path)
33
+
34
+
35
+ def _fail(message: str, code: int = EXIT_CONFIG) -> None:
36
+ click.echo(f"❌ {message}", err=True)
37
+ sys.exit(code)
38
+
39
+
40
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
41
+ @click.version_option(version=__version__, prog_name="adbc-replicate")
42
+ def main() -> None:
43
+ """πŸ” adbc-replicate β€” stream whole schemas from one database into another over ADBC.
44
+
45
+ Describe what to replicate in a YAML config, check it with `plan`, then `run` it.
46
+ """
47
+
48
+
49
+ @main.command()
50
+ @CONFIG_OPTION
51
+ @ENV_FILE_OPTION
52
+ @click.option("--full-refresh", is_flag=True, help="Copy every table and recreate every view, ignoring sync state.")
53
+ @click.option("--row-limit", type=click.IntRange(min=0), default=None,
54
+ help="Copy at most N rows per table: a quick end-to-end trial. Sampled tables are re-copied in "
55
+ "full by the next normal run.")
56
+ @click.option("--tables", "only", multiple=True, metavar="PATTERN",
57
+ help="Only these DB_SCHEMA.NAME patterns (repeatable), e.g. --tables 'HR.EMP*'.")
58
+ @click.option("--parallelism", type=click.IntRange(min=1, max=64), default=None,
59
+ help="Override the config's parallelism.")
60
+ @click.option("--skip-views", is_flag=True, help="Copy tables only.")
61
+ @click.option("--log-format", type=click.Choice(["text", "json"]), default="text", show_default=True,
62
+ help="json: one event object per line (for log pipelines and UIs).")
63
+ @click.option("-q", "--quiet", is_flag=True, help="Text mode: only warnings, errors and the summary.")
64
+ def run(config_path, env_file, full_refresh, row_limit, only, parallelism, skip_views, log_format, quiet) -> None:
65
+ """Run the replication."""
66
+ try:
67
+ config = _load(config_path=config_path, env_file=env_file)
68
+ except ConfigError as e:
69
+ _fail(message=str(e))
70
+ if parallelism and parallelism > 1 and config.source.consistency == "serializable":
71
+ _fail(message="consistency 'serializable' needs parallelism 1")
72
+ reporter = Reporter(fmt=log_format, quiet=quiet)
73
+ started = time.monotonic()
74
+ replicator = Replicator(config=config, reporter=reporter,
75
+ options=RunOptions(full_refresh=full_refresh, row_limit=row_limit, only=list(only),
76
+ parallelism=parallelism, skip_views=skip_views))
77
+ try:
78
+ summary = replicator.run()
79
+ except PlanError as e:
80
+ reporter.emit(event="run_failed", text=f"❌ {e}", level="error", error=str(e))
81
+ sys.exit(EXIT_CONFIG)
82
+ except Exception as e:
83
+ reporter.emit(event="run_failed", text=f"πŸ’₯ {type(e).__name__}: {e}", level="error",
84
+ error=f"{type(e).__name__}: {e}")
85
+ sys.exit(EXIT_FAILED)
86
+ reporter.emit(event="elapsed", text=f"⏱️ {fmt_seconds(time.monotonic() - started)}",
87
+ seconds=round(time.monotonic() - started, 2))
88
+ sys.exit(summary.exit_code)
89
+
90
+
91
+ @main.command()
92
+ @CONFIG_OPTION
93
+ @ENV_FILE_OPTION
94
+ @click.option("--format", "fmt", type=click.Choice(["text", "json"]), default="text", show_default=True)
95
+ @click.option("--show-sql", is_flag=True, help="Print each table's SELECT and each view's translated DDL.")
96
+ @click.option("--tables", "only", multiple=True, metavar="PATTERN", help="Only these DB_SCHEMA.NAME patterns.")
97
+ def plan(config_path, env_file, fmt, show_sql, only) -> None:
98
+ """Show what a run would copy, without writing anything.
99
+
100
+ Connects to the source only: lists the matching tables and views, their target names,
101
+ excluded and masked columns, and whether each view translates.
102
+ """
103
+ try:
104
+ config = _load(config_path=config_path, env_file=env_file)
105
+ except ConfigError as e:
106
+ _fail(message=str(e))
107
+ replicator = Replicator(config=config, reporter=Reporter(fmt="text", quiet=True),
108
+ options=RunOptions(only=list(only)))
109
+ source = replicator.source
110
+ conn = source.connect()
111
+ try:
112
+ try:
113
+ the_plan = replicator.build_plan(source_conn=conn)
114
+ except PlanError as e:
115
+ _fail(message=str(e))
116
+ definitions = {}
117
+ for catalog in sorted({v.source.catalog for v in the_plan.views}, key=lambda c: c or ""):
118
+ in_catalog = [v.source for v in the_plan.views if v.source.catalog == catalog]
119
+ definitions.update(source.view_definitions(conn=conn, catalog=catalog, views=in_catalog))
120
+
121
+ tables_out = []
122
+ for t in the_plan.tables:
123
+ tables_out.append({
124
+ "source": t.source.qualified_name, "source_kind": t.source.kind,
125
+ "target": f"{t.target_db_schema}.{t.target_table}",
126
+ "columns": len(t.columns), "excluded_columns": t.excluded_columns,
127
+ "masked_columns": {c.column.name: c.mask.strategy for c in t.columns if c.mask},
128
+ "fingerprint": "yes" if source.fingerprint_sql(task=t) else "no (copied every run)",
129
+ "note": t.note,
130
+ **({"sql": source.select_sql(task=t)} if show_sql else {}),
131
+ })
132
+ views_out = []
133
+ for v in the_plan.views:
134
+ entry = {"source": v.source.qualified_name, "target": f"{v.target_db_schema}.{v.target_view}"}
135
+ override = load_override(overrides_dir=config.views.overrides_dir, db_schema=v.source.db_schema,
136
+ view=v.source.name)
137
+ try:
138
+ if override is not None:
139
+ sql = create_view_sql(target_db_schema=v.target_db_schema, target_view=v.target_view, body=override,
140
+ target_catalog=config.target.catalog)
141
+ entry["status"] = "override"
142
+ else:
143
+ definition = definitions.get((v.source.db_schema, v.source.name))
144
+ if not definition:
145
+ raise ViewTranslationError("the source returned no definition (no privilege on it?)")
146
+ sql = translate_view(definition=definition, source_dialect=source.dialect,
147
+ view_db_schema=v.source.db_schema, target_db_schema=v.target_db_schema,
148
+ target_view=v.target_view, name_map=the_plan.name_map,
149
+ case=config.naming.case, source_columns=[c.name for c in v.source.columns],
150
+ target_catalog=config.target.catalog)
151
+ entry["status"] = "translates"
152
+ if show_sql:
153
+ entry["sql"] = sql
154
+ except Exception as e:
155
+ entry["status"] = "fails"
156
+ entry["error"] = str(e)
157
+ views_out.append(entry)
158
+ finally:
159
+ with suppress(Exception):
160
+ conn.close()
161
+
162
+ result = {"replication": config.name, "source": config.source.type, "target": config.target.type,
163
+ "tables": tables_out, "views": views_out, "warnings": the_plan.warnings}
164
+ if fmt == "json":
165
+ click.echo(json.dumps(result, indent=2, default=str))
166
+ return
167
+
168
+ title = f" for {config.name}" if config.name else ""
169
+ click.echo(f"πŸ—ΊοΈ Plan{title}: {config.source.type} β†’ {config.target.type}\n")
170
+ click.echo(f"πŸ“¦ Tables ({len(tables_out)})")
171
+ for t in tables_out:
172
+ extras = []
173
+ if t["note"]:
174
+ extras.append(t["note"])
175
+ if t["excluded_columns"]:
176
+ extras.append("excluded: " + ", ".join(t["excluded_columns"]))
177
+ if t["masked_columns"]:
178
+ extras.append("masked: " + ", ".join(f"{k} ({v})" for k, v in t["masked_columns"].items()))
179
+ if t["fingerprint"] != "yes":
180
+ extras.append("no fingerprint: copied every run")
181
+ click.echo(f" β€’ {t['source']} β†’ {t['target']} [{t['columns']} columns]"
182
+ + (f" ({'; '.join(extras)})" if extras else ""))
183
+ if show_sql:
184
+ click.echo(f" {t['sql']}")
185
+ click.echo(f"\nπŸͺŸ Views ({len(views_out)})")
186
+ icons = {"translates": "βœ…", "override": "πŸ“", "fails": "❌"}
187
+ for v in views_out:
188
+ click.echo(f" {icons[v['status']]} {v['source']} β†’ {v['target']}"
189
+ + (f" β€” {v['error'].splitlines()[0]}" if v.get("error") else ""))
190
+ if show_sql and v.get("sql"):
191
+ click.echo(" " + v["sql"].replace("\n", "\n "))
192
+ if the_plan.warnings:
193
+ click.echo(f"\n⚠️ Warnings ({len(the_plan.warnings)})")
194
+ for w in the_plan.warnings:
195
+ click.echo(f" β€’ {w}")
196
+ failing = sum(v["status"] == "fails" for v in views_out)
197
+ click.echo(f"\n✨ {len(tables_out)} table(s) to copy, {len(views_out) - failing} view(s) ready"
198
+ + (f", {failing} view(s) won't translate (add an override, or views.on_failure: materialize)"
199
+ if failing else ""))
200
+
201
+
202
+ @main.command("test-connection")
203
+ @CONFIG_OPTION
204
+ @ENV_FILE_OPTION
205
+ def test_connection(config_path, env_file) -> None:
206
+ """Connect to the source and the target and report what each sees."""
207
+ try:
208
+ config = _load(config_path=config_path, env_file=env_file)
209
+ except ConfigError as e:
210
+ _fail(message=str(e))
211
+ replicator = Replicator(config=config)
212
+ ok = True
213
+ try:
214
+ started = time.monotonic()
215
+ conn = replicator.source.connect()
216
+ try:
217
+ catalog = replicator.source.current_catalog(conn=conn)
218
+ db_schemas = replicator.source.list_db_schemas(conn=conn, catalog=None)
219
+ finally:
220
+ conn.close()
221
+ click.echo(f"βœ… Source ({config.source.type}): connected in {fmt_seconds(time.monotonic() - started)}; "
222
+ f"catalog {catalog}, {len(db_schemas)} db_schema(s) visible")
223
+ except Exception as e:
224
+ ok = False
225
+ click.echo(f"❌ Source ({config.source.type}): {type(e).__name__}: {e}")
226
+ try:
227
+ started = time.monotonic()
228
+ conn = replicator.target.connect()
229
+ try:
230
+ version = replicator.target.query(conn=conn, sql="SELECT version()")[0][0]
231
+ finally:
232
+ conn.close()
233
+ click.echo(f"βœ… Target ({config.target.type}): connected in {fmt_seconds(time.monotonic() - started)}; "
234
+ f"catalog {replicator.target.catalog}, DuckDB {version}")
235
+ except Exception as e:
236
+ ok = False
237
+ click.echo(f"❌ Target ({config.target.type}): {type(e).__name__}: {e}")
238
+ sys.exit(EXIT_OK if ok else EXIT_FAILED)
239
+
240
+
241
+ @main.command()
242
+ @CONFIG_OPTION
243
+ @ENV_FILE_OPTION
244
+ def validate(config_path, env_file) -> None:
245
+ """Check a config file without connecting to anything."""
246
+ try:
247
+ config = _load(config_path=config_path, env_file=env_file)
248
+ except ConfigError as e:
249
+ _fail(message=str(e))
250
+ click.echo(f"βœ… {config_path} is valid: {config.source.type} β†’ {config.target.type}, "
251
+ f"{len(config.objects)} object rule(s), {len(config.columns)} column rule(s)")
252
+
253
+
254
+ @main.command()
255
+ def schema() -> None:
256
+ """Print the config's JSON Schema (for editors, CI checks and UIs)."""
257
+ click.echo(json.dumps(json_schema(), indent=2))
258
+
259
+
260
+ EXAMPLE_CONFIG = """\
261
+ # adbc-replicate config β€” see https://github.com/gizmodata/adbc-replicate
262
+ version: 1
263
+ name: oracle-to-gizmosql
264
+
265
+ source:
266
+ type: oracle
267
+ uri: oracle://oracle.example.com:1521/ORCLPDB1
268
+ username: ${ORACLE_USERNAME}
269
+ password: ${ORACLE_PASSWORD}
270
+ consistency: serializable # every table read from one point in time
271
+
272
+ target:
273
+ type: gizmosql
274
+ uri: gizmosql://gizmosql.example.com:31337
275
+ username: ${GIZMOSQL_USERNAME}
276
+ password: ${GIZMOSQL_PASSWORD}
277
+
278
+ objects:
279
+ - db_schemas: [HR, SALES]
280
+ exclude_tables: ["*_BAK", "/^TMP_/"]
281
+
282
+ columns:
283
+ - tables: [HR.EMPLOYEES]
284
+ exclude: [SSN]
285
+ mask:
286
+ PHONE_NUMBER: hash
287
+ """
288
+
289
+
290
+ @main.command()
291
+ @click.argument("path", type=click.Path(dir_okay=False), default="replicate.yaml")
292
+ def init(path) -> None:
293
+ """Write an example config to PATH (default replicate.yaml)."""
294
+ p = Path(path)
295
+ if p.exists():
296
+ _fail(message=f"{path} already exists")
297
+ p.write_text(EXAMPLE_CONFIG, encoding="utf-8")
298
+ click.echo(f"πŸ“ Wrote {path}. Edit it, then: adbc-replicate plan --config {path}")
299
+
300
+
301
+ if __name__ == "__main__":
302
+ main()