inthub-cli 0.1.4__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,123 @@
1
+ from pathlib import Path
2
+ from typing import Annotated
3
+
4
+ import typer
5
+ from ulid import ULID
6
+
7
+ from inthub import config
8
+ from inthub.bulk import csv_state
9
+ from inthub.client import InthubClient
10
+ from inthub.commands.bulk.src_sqlserver_sink_snowflake import orchestrator
11
+
12
+ app = typer.Typer(
13
+ help="Create SQL Server source and Snowflake sink connectors in bulk.",
14
+ add_completion=False,
15
+ )
16
+
17
+
18
+ def _client() -> InthubClient:
19
+ return InthubClient(config.require_token())
20
+
21
+
22
+ @app.callback(invoke_without_command=True)
23
+ def src_sqlserver_sink_snowflake(
24
+ ctx: typer.Context,
25
+ csv_file: Annotated[Path | None, typer.Option("--csv", help="Path to input CSV.")] = None,
26
+ sqlserver_source_plugin: Annotated[
27
+ str | None, typer.Option("--sqlserver-source-plugin")
28
+ ] = None,
29
+ snowflake_sink_plugin: Annotated[
30
+ str | None, typer.Option("--snowflake-sink-plugin")
31
+ ] = None,
32
+ tags: Annotated[
33
+ str | None, typer.Option("--tags", help="Comma-separated tags.")
34
+ ] = None,
35
+ gcp_project: Annotated[
36
+ str | None, typer.Option("--gcp-project", help="GCP project ID for Secret Manager.")
37
+ ] = None,
38
+ resume: Annotated[
39
+ str | None, typer.Option("--resume", help="ULID of a previous run to resume.")
40
+ ] = None,
41
+ ) -> None:
42
+ """Provision SQL Server source and Snowflake sink connectors from a CSV."""
43
+ if ctx.invoked_subcommand is not None:
44
+ return
45
+
46
+ if resume and any(x is not None for x in [csv_file, sqlserver_source_plugin,
47
+ snowflake_sink_plugin, tags, gcp_project]):
48
+ typer.echo(
49
+ "Error: --resume is mutually exclusive with --csv, "
50
+ "--sqlserver-source-plugin, --snowflake-sink-plugin, --tags, --gcp-project.",
51
+ err=True,
52
+ )
53
+ raise typer.Exit(1)
54
+
55
+ if resume:
56
+ try:
57
+ state = csv_state.load_state(resume)
58
+ except FileNotFoundError:
59
+ typer.echo(f"Error: state for ULID '{resume}' not found in ~/.inthub/.", err=True)
60
+ raise typer.Exit(1)
61
+ progress_path = csv_state._progress_path(resume)
62
+ if not progress_path.exists():
63
+ typer.echo(f"Error: progress file '{progress_path}' not found.", err=True)
64
+ raise typer.Exit(1)
65
+ source_plugin = state["sqlserver_source_plugin"]
66
+ sink_plugin = state["snowflake_sink_plugin"]
67
+ tag_list = [t for t in state.get("tags", "").split(",") if t]
68
+ resolved_gcp_project = state["gcp_project"]
69
+ else:
70
+ missing = [
71
+ name for name, val in [
72
+ ("--csv", csv_file),
73
+ ("--sqlserver-source-plugin", sqlserver_source_plugin),
74
+ ("--snowflake-sink-plugin", snowflake_sink_plugin),
75
+ ("--tags", tags),
76
+ ("--gcp-project", gcp_project),
77
+ ] if val is None
78
+ ]
79
+ if missing:
80
+ typer.echo(f"Error: missing required options: {', '.join(missing)}", err=True)
81
+ raise typer.Exit(1)
82
+
83
+ assert csv_file is not None
84
+ assert sqlserver_source_plugin is not None
85
+ assert snowflake_sink_plugin is not None
86
+ assert gcp_project is not None
87
+
88
+ if not csv_file.exists():
89
+ typer.echo(f"Error: CSV file '{csv_file}' not found.", err=True)
90
+ raise typer.Exit(1)
91
+
92
+ try:
93
+ csv_state.validate_columns(csv_file)
94
+ except ValueError as exc:
95
+ typer.echo(f"Error: {exc}", err=True)
96
+ raise typer.Exit(1)
97
+
98
+ ulid = str(ULID())
99
+ progress_path = csv_state.init_progress_csv(csv_file, ulid)
100
+ tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()]
101
+ source_plugin = sqlserver_source_plugin
102
+ sink_plugin = snowflake_sink_plugin
103
+ resolved_gcp_project = gcp_project
104
+
105
+ csv_state.save_state(
106
+ ulid,
107
+ gcp_project=resolved_gcp_project,
108
+ sqlserver_source_plugin=source_plugin,
109
+ snowflake_sink_plugin=sink_plugin,
110
+ tags=tags or "",
111
+ )
112
+
113
+ typer.echo(ulid)
114
+
115
+ orchestrator.run(
116
+ progress_path=progress_path,
117
+ client=_client(),
118
+ company_slug=config.require_company_slug(),
119
+ source_plugin=source_plugin,
120
+ sink_plugin=sink_plugin,
121
+ tags=tag_list,
122
+ gcp_project=resolved_gcp_project,
123
+ )
@@ -0,0 +1,134 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from inthub.client import InthubClient
5
+
6
+
7
+ def build_source_config(
8
+ row: dict[str, str],
9
+ cluster_slug: str,
10
+ company_slug: str,
11
+ kcc_bootstrap: str,
12
+ ) -> str:
13
+ secret_ref = (
14
+ f"${{secrets:inthub-{company_slug}/"
15
+ f"{row['sqlserver_gcp_secret']}-{cluster_slug}"
16
+ )
17
+ cfg: dict[str, Any] = {
18
+ "database.hostname": row["sqlserver_instance"],
19
+ "database.port": row["sqlserver_port"],
20
+ "database.user": f"{secret_ref}:username}}",
21
+ "database.password": f"{secret_ref}:password}}",
22
+ "database.names": row["sqlserver_db"],
23
+ "database.encrypt": False,
24
+ "topic.prefix": row["connector_name"],
25
+ "table.include.list": ", ".join(
26
+ f"dbo.{t.strip()}" for t in row["tables"].split(",")
27
+ ),
28
+ "decimal.handling.mode": "string",
29
+ "tombstones.on.delete": False,
30
+ "schema.history.internal.kafka.bootstrap.servers": kcc_bootstrap,
31
+ "schema.history.internal.kafka.topic": f"sh_{row['connector_name']}",
32
+ "data.query.mode": "direct",
33
+ "key.converter": "io.confluent.connect.avro.AvroConverter",
34
+ "key.converter.schema.registry.url": "http://schema-registry.inthub:8081",
35
+ "key.converter.schemas.enable": True,
36
+ "value.converter": "io.confluent.connect.avro.AvroConverter",
37
+ "value.converter.schema.registry.url": "http://schema-registry.inthub:8081",
38
+ "value.converter.schemas.enable": True,
39
+ "snapshot.locking.mode": "none",
40
+ "snapshot.isolation.mode": "read_committed",
41
+ "snapshot.max.threads": 5,
42
+ }
43
+ return json.dumps(cfg)
44
+
45
+
46
+ def build_sink_config(
47
+ row: dict[str, str],
48
+ table: str,
49
+ cluster_slug: str,
50
+ company_slug: str,
51
+ ) -> str:
52
+ secret_ref = (
53
+ f"${{secrets:inthub-{company_slug}/"
54
+ f"{row['snowflake_gcp_secret']}-{cluster_slug}"
55
+ )
56
+ topic = f"{row['connector_name']}.{row['sqlserver_db']}.dbo.{table}"
57
+ url = (
58
+ f"jdbc:snowflake://{row['snowflake_account']}"
59
+ f"?schema={row['sqlserver_db']}&db={row['snowflake_db']}"
60
+ f"&warehouse={row['snowflake_wh']}&CLIENT_SESSION_KEEP_ALIVE=TRUE&tracing=WARNING"
61
+ )
62
+ cfg: dict[str, Any] = {
63
+ "topics": topic,
64
+ "url": url,
65
+ "user": f"{secret_ref}:username}}",
66
+ "password": f"{secret_ref}:password}}",
67
+ "schema": row["sqlserver_db"],
68
+ "key.converter": "io.confluent.connect.avro.AvroConverter",
69
+ "key.converter.schema.registry.url": "http://schema-registry.inthub:8081",
70
+ "key.converter.schemas.enable": True,
71
+ "value.converter": "io.confluent.connect.avro.AvroConverter",
72
+ "value.converter.schema.registry.url": "http://schema-registry.inthub:8081",
73
+ "value.converter.schemas.enable": True,
74
+ "consumer.override.max.poll.records": 1000,
75
+ }
76
+ return json.dumps(cfg)
77
+
78
+
79
+ def create_source_connector(
80
+ client: InthubClient,
81
+ row: dict[str, str],
82
+ cluster_slug: str,
83
+ company_slug: str,
84
+ kcc_bootstrap: str,
85
+ kcc_name: str,
86
+ plugin: str,
87
+ tags: list[str],
88
+ ) -> str:
89
+ name = f"{row['connector_name']}-{cluster_slug}"
90
+ configs = build_source_config(row, cluster_slug, company_slug, kcc_bootstrap)
91
+ body = client.post("/connectors", json={
92
+ "name": name,
93
+ "kafkaConnectCluster": kcc_name,
94
+ "plugin": plugin,
95
+ "tags": tags,
96
+ "configs": configs,
97
+ "tasksMax": 1,
98
+ }).json()
99
+ connector_id: str = body["id"]
100
+ client.post(f"/connectors/{connector_id}/apply", json={
101
+ "branch": "main",
102
+ "commitMessage": f"Bulk: create source connector {name}",
103
+ "authorName": "inthub-cli",
104
+ })
105
+ return connector_id
106
+
107
+
108
+ def create_sink_connector(
109
+ client: InthubClient,
110
+ row: dict[str, str],
111
+ table: str,
112
+ cluster_slug: str,
113
+ company_slug: str,
114
+ kcc_name: str,
115
+ plugin: str,
116
+ tags: list[str],
117
+ ) -> str:
118
+ name = f"{row['connector_name']}-{cluster_slug}"
119
+ configs = build_sink_config(row, table, cluster_slug, company_slug)
120
+ body = client.post("/connectors", json={
121
+ "name": name,
122
+ "kafkaConnectCluster": kcc_name,
123
+ "plugin": plugin,
124
+ "tags": tags,
125
+ "configs": configs,
126
+ "tasksMax": 1,
127
+ }).json()
128
+ connector_id: str = body["id"]
129
+ client.post(f"/connectors/{connector_id}/apply", json={
130
+ "branch": "main",
131
+ "commitMessage": f"Bulk: create sink connector {name} for table {table}",
132
+ "authorName": "inthub-cli",
133
+ })
134
+ return connector_id
@@ -0,0 +1,326 @@
1
+ import time
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from rich.console import Console
6
+ from rich.rule import Rule
7
+
8
+ from inthub.bulk import csv_state, ddl_converter, gcp_secrets, snowflake_client, sqlserver
9
+ from inthub.client import InthubClient
10
+ from inthub.commands.bulk.src_sqlserver_sink_snowflake import connectors as conn_builder
11
+
12
+ console = Console()
13
+ err = Console(stderr=True)
14
+
15
+ _ARGOCD_TIMEOUT = 600
16
+ _ARGOCD_POLL = 10
17
+ _CONNECTOR_TIMEOUT = 600
18
+ _CONNECTOR_POLL = 15
19
+
20
+ _OK = "[bold green]✓[/]"
21
+ _FAIL = "[bold red]✗[/]"
22
+
23
+
24
+ def _step_ok(n: int, total: int, msg: str) -> None:
25
+ console.print(f" [dim][{n}/{total}][/] {msg} {_OK}")
26
+
27
+
28
+ def _step_fail(n: int, total: int, msg: str, reason: str) -> None:
29
+ console.print(f" [dim][{n}/{total}][/] {msg} {_FAIL}")
30
+ console.print(f" [bold red]Error:[/] {reason}")
31
+
32
+
33
+ def _unwrap_list(body: Any) -> list[dict[str, Any]]:
34
+ if isinstance(body, list):
35
+ return [item for item in body if isinstance(item, dict)]
36
+ if isinstance(body, dict):
37
+ for val in body.values():
38
+ if isinstance(val, list):
39
+ return [item for item in val if isinstance(item, dict)]
40
+ return []
41
+
42
+
43
+ def _resolve_kcc(client: InthubClient, kcc_name: str) -> dict[str, Any]:
44
+ body = client.get("/kafka-connect-clusters").json()
45
+ for c in _unwrap_list(body):
46
+ if c.get("name") == kcc_name:
47
+ return c
48
+ return {}
49
+
50
+
51
+ def _list_connector_names(client: InthubClient) -> set[str]:
52
+ body = client.get("/connectors").json()
53
+ return {str(c.get("name", "")) for c in _unwrap_list(body)}
54
+
55
+
56
+ def _list_secret_names(client: InthubClient) -> dict[str, dict[str, Any]]:
57
+ body = client.get("/secrets").json()
58
+ return {str(s.get("name", "")): s for s in _unwrap_list(body)}
59
+
60
+
61
+ def _poll_argocd(
62
+ client: InthubClient,
63
+ resource_name: str,
64
+ timeout: int,
65
+ poll: int,
66
+ ) -> bool:
67
+ deadline = time.time() + timeout
68
+ while time.time() < deadline:
69
+ resp = client.get(f"/observability/argocd-status?name={resource_name}").json()
70
+ if resp.get("argoHealth") == "Healthy" and resp.get("argoSync") == "Synced":
71
+ return True
72
+ time.sleep(poll)
73
+ return False
74
+
75
+
76
+ def _ensure_secret(
77
+ client: InthubClient,
78
+ suffixed_name: str,
79
+ existing: dict[str, dict[str, Any]],
80
+ ) -> str | None:
81
+ if suffixed_name in existing:
82
+ s = existing[suffixed_name]
83
+ if s.get("argoHealth") == "Healthy" and s.get("argoSync") == "Synced":
84
+ return None
85
+
86
+ if suffixed_name not in existing:
87
+ client.post("/secrets", json={"name": suffixed_name})
88
+
89
+ if not _poll_argocd(client, suffixed_name, _ARGOCD_TIMEOUT, _ARGOCD_POLL):
90
+ return f"secret '{suffixed_name}' did not sync within timeout"
91
+ return None
92
+
93
+
94
+ def _validate_plugins(client: InthubClient, source_plugin: str, sink_plugin: str) -> str | None:
95
+ body = client.get("/kc-plugins").json()
96
+ names = {p.get("name") for p in _unwrap_list(body)}
97
+ if source_plugin not in names:
98
+ return f"plugin '{source_plugin}' not found"
99
+ if sink_plugin not in names:
100
+ return f"plugin '{sink_plugin}' not found"
101
+ return None
102
+
103
+
104
+ _TOTAL_STEPS = 9
105
+
106
+
107
+ def process_row(
108
+ row: dict[str, str],
109
+ progress_path: Path,
110
+ client: InthubClient,
111
+ company_slug: str,
112
+ source_plugin: str,
113
+ sink_plugin: str,
114
+ tags: list[str],
115
+ gcp_project: str,
116
+ ) -> None:
117
+ name = row["connector_name"]
118
+ csv_state.update_row(progress_path, name, "working")
119
+
120
+ def fail(n: int, msg: str, reason: str) -> None:
121
+ csv_state.update_row(progress_path, name, "error", reason)
122
+ _step_fail(n, _TOTAL_STEPS, msg, reason)
123
+
124
+ try:
125
+ # ── Step 1: Resolve KCCs ─────────────────────────────────────────
126
+ _msg1 = f"Resolving KCCs [cyan]{row['kcc_source']}[/] / [cyan]{row['kcc_sink']}[/]"
127
+ kcc_src = _resolve_kcc(client, row["kcc_source"])
128
+ if not kcc_src:
129
+ fail(1, _msg1, f"KCC source '{row['kcc_source']}' not found")
130
+ return
131
+ kcc_snk = _resolve_kcc(client, row["kcc_sink"])
132
+ if not kcc_snk:
133
+ fail(1, _msg1, f"KCC sink '{row['kcc_sink']}' not found")
134
+ return
135
+ src_cluster_slug: str = str(kcc_src.get("slug", ""))
136
+ kcc_bootstrap: str = str(kcc_src.get("bootstrapServers", ""))
137
+ src_kcc_name: str = str(kcc_src.get("name", row["kcc_source"]))
138
+ snk_cluster_slug: str = str(kcc_snk.get("slug", ""))
139
+ snk_kcc_name: str = str(kcc_snk.get("name", row["kcc_sink"]))
140
+ _step_ok(1, _TOTAL_STEPS, _msg1)
141
+
142
+ # ── Step 2: Fetch GCP secrets ────────────────────────────────────
143
+ _msg2 = "Fetching GCP secrets"
144
+ try:
145
+ ss_creds = gcp_secrets.fetch_secret(
146
+ gcp_project, f"{row['sqlserver_gcp_secret']}-{src_cluster_slug}"
147
+ )
148
+ except Exception as exc:
149
+ fail(2, _msg2, str(exc))
150
+ return
151
+ try:
152
+ sf_creds = gcp_secrets.fetch_secret(
153
+ gcp_project, f"{row['snowflake_gcp_secret']}-{snk_cluster_slug}"
154
+ )
155
+ except Exception as exc:
156
+ fail(2, _msg2, str(exc))
157
+ return
158
+ _step_ok(2, _TOTAL_STEPS, _msg2)
159
+
160
+ # ── Step 3: Connect to SQL Server and Snowflake ──────────────────
161
+ _msg3 = "Connecting to SQL Server and Snowflake"
162
+ try:
163
+ ss_conn = sqlserver.connect(
164
+ row["sqlserver_instance"], int(row["sqlserver_port"]),
165
+ row["sqlserver_db"], ss_creds["username"], ss_creds["password"],
166
+ )
167
+ except Exception as exc:
168
+ fail(3, _msg3, f"SQL Server connection failed: {exc}")
169
+ return
170
+ try:
171
+ sf_conn = snowflake_client.connect(
172
+ row["snowflake_account"], sf_creds["username"], sf_creds["password"],
173
+ )
174
+ except Exception as exc:
175
+ fail(3, _msg3, f"Snowflake connection failed: {exc}")
176
+ return
177
+ _step_ok(3, _TOTAL_STEPS, _msg3)
178
+
179
+ # ── Step 4: Validate tables (CDC) and provision Snowflake ────────
180
+ tables = [t.strip() for t in row["tables"].split(",")]
181
+ _msg4 = f"Validating {len(tables)} table(s) and provisioning Snowflake"
182
+ for table in tables:
183
+ if not sqlserver.table_exists(ss_conn, row["sqlserver_db"], table):
184
+ fail(4, _msg4, f"Table '{table}' not found in SQL Server")
185
+ return
186
+ if not sqlserver.cdc_enabled(ss_conn, row["sqlserver_db"], table):
187
+ fail(4, _msg4, f"CDC not enabled on table '{table}'")
188
+ return
189
+ if not snowflake_client.ingest_table_exists(
190
+ sf_conn, row["snowflake_db"], row["sqlserver_db"], table
191
+ ):
192
+ columns = sqlserver.get_columns(ss_conn, row["sqlserver_db"], table)
193
+ ddl = ddl_converter.to_create_table_ddl(
194
+ row["snowflake_db"], row["sqlserver_db"], table, columns
195
+ )
196
+ snowflake_client.create_ingest_table(sf_conn, ddl)
197
+ col_names = [str(c["COLUMN_NAME"]) for c in columns]
198
+ snowflake_client.create_view(
199
+ sf_conn, row["snowflake_db"], row["sqlserver_db"], table, col_names
200
+ )
201
+ snowflake_client.create_stage(
202
+ sf_conn, row["snowflake_db"], row["sqlserver_db"], table
203
+ )
204
+ _step_ok(4, _TOTAL_STEPS, _msg4)
205
+
206
+ # ── Step 5: Check connectors don't already exist ──────────────────
207
+ src_connector_name = f"{name}-{src_cluster_slug}"
208
+ snk_connector_name = f"{name}-{snk_cluster_slug}"
209
+ _msg5 = (
210
+ f"Checking connector availability "
211
+ f"[cyan]{src_connector_name}[/] / [cyan]{snk_connector_name}[/]"
212
+ )
213
+ existing_connectors = _list_connector_names(client)
214
+ for cname in (src_connector_name, snk_connector_name):
215
+ if cname in existing_connectors:
216
+ fail(5, _msg5, f"connector '{cname}' already exists")
217
+ return
218
+ _step_ok(5, _TOTAL_STEPS, _msg5)
219
+
220
+ # ── Step 6: Ensure secrets exist and are synced ──────────────────
221
+ ss_secret_name = f"{row['sqlserver_gcp_secret']}-{src_cluster_slug}"
222
+ sf_secret_name = f"{row['snowflake_gcp_secret']}-{snk_cluster_slug}"
223
+ _msg6 = "Ensuring secrets synced in ArgoCD"
224
+ existing_secrets = _list_secret_names(client)
225
+ for secret_name in (ss_secret_name, sf_secret_name):
226
+ err_msg = _ensure_secret(client, secret_name, existing_secrets)
227
+ if err_msg:
228
+ fail(6, _msg6, err_msg)
229
+ return
230
+ _step_ok(6, _TOTAL_STEPS, _msg6)
231
+
232
+ # ── Step 7: Create source connector ──────────────────────────────
233
+ _msg7 = f"Creating source connector [cyan]{src_connector_name}[/]"
234
+ try:
235
+ src_id = conn_builder.create_source_connector(
236
+ client, row, src_cluster_slug, company_slug, kcc_bootstrap, src_kcc_name,
237
+ source_plugin, tags,
238
+ )
239
+ except Exception as exc:
240
+ fail(7, _msg7, f"failed to create source connector: {exc}")
241
+ return
242
+ _step_ok(7, _TOTAL_STEPS, _msg7)
243
+
244
+ # ── Step 8: Create sink connectors ───────────────────────────────
245
+ _msg8 = f"Creating {len(tables)} sink connector(s)"
246
+ sink_ids: list[str] = []
247
+ for table in tables:
248
+ try:
249
+ sid = conn_builder.create_sink_connector(
250
+ client, row, table, snk_cluster_slug, company_slug, snk_kcc_name,
251
+ sink_plugin, tags,
252
+ )
253
+ sink_ids.append(sid)
254
+ except Exception as exc:
255
+ fail(8, _msg8, f"failed to create sink connector for '{table}': {exc}")
256
+ return
257
+ _step_ok(8, _TOTAL_STEPS, _msg8)
258
+
259
+ # ── Step 9: Wait for ArgoCD and verify data ───────────────────────
260
+ _msg9 = "ArgoCD convergence and Snowflake data verified"
261
+ for cid in [src_id, *sink_ids]:
262
+ if not _poll_argocd(client, cid, _CONNECTOR_TIMEOUT, _CONNECTOR_POLL):
263
+ fail(9, _msg9, "ArgoCD convergence timeout")
264
+ return
265
+ for table in tables:
266
+ count = snowflake_client.row_count(
267
+ sf_conn, row["snowflake_db"], row["sqlserver_db"], table
268
+ )
269
+ if count < 1:
270
+ fail(9, _msg9, f"No records in '{table}' after convergence")
271
+ return
272
+ _step_ok(9, _TOTAL_STEPS, _msg9)
273
+
274
+ csv_state.update_row(progress_path, name, "done")
275
+ console.print(f" {_OK} [bold green]{name} completed[/]")
276
+
277
+ except Exception as exc:
278
+ csv_state.update_row(progress_path, name, "error", str(exc))
279
+ console.print(f" {_FAIL} Unexpected error")
280
+ console.print(f" [bold red]Error:[/] {exc}")
281
+
282
+
283
+ def run(
284
+ progress_path: Path,
285
+ client: InthubClient,
286
+ company_slug: str,
287
+ source_plugin: str,
288
+ sink_plugin: str,
289
+ tags: list[str],
290
+ gcp_project: str,
291
+ ) -> None:
292
+ err_msg = _validate_plugins(client, source_plugin, sink_plugin)
293
+ if err_msg:
294
+ err.print(f"[bold red]Plugin validation failed:[/] {err_msg}")
295
+ raise SystemExit(1)
296
+
297
+ rows = csv_state.load_rows(progress_path)
298
+ for row in rows:
299
+ if row.get("status") == "working":
300
+ csv_state.update_row(progress_path, row["connector_name"], "pending")
301
+
302
+ rows = csv_state.load_rows(progress_path)
303
+ pending = [r for r in rows if r.get("status") != "done"]
304
+ total = len(rows)
305
+
306
+ for i, row in enumerate(rows):
307
+ if row.get("status") == "done":
308
+ continue
309
+ pos = i + 1
310
+ console.print(Rule(
311
+ f"[bold]Row {pos}/{total}: {row['connector_name']}[/]",
312
+ style="cyan",
313
+ ))
314
+ process_row(
315
+ row, progress_path, client, company_slug,
316
+ source_plugin, sink_plugin, tags, gcp_project,
317
+ )
318
+ console.print()
319
+
320
+ done = sum(1 for r in csv_state.load_rows(progress_path) if r.get("status") == "done")
321
+ errors = sum(1 for r in csv_state.load_rows(progress_path) if r.get("status") == "error")
322
+ console.print(Rule(style="dim"))
323
+ console.print(
324
+ f" Finished: [green]{done} done[/] [red]{errors} error(s)[/]"
325
+ f" out of {len(pending)} processed"
326
+ )