baucli 1.0.1__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.
baucli/sync.py ADDED
@@ -0,0 +1,846 @@
1
+ """BAUCLI — Smart Sync engine.
2
+
3
+ Orchestrates: check server status → decide FULL/DELTA → extract → batch → send.
4
+ Supports multi-db with --db flag.
5
+ """
6
+
7
+ import hashlib
8
+ import re
9
+ from typing import Optional
10
+ from rich.console import Console
11
+ from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn, MofNCompleteColumn
12
+ from rich.table import Table as RichTable
13
+
14
+ from baucli.config import get_database, get_db_password, load_config
15
+ from baucli.api import BauApi, BauApiError
16
+ from baucli.db import BauConnectionError
17
+
18
+ console = Console()
19
+
20
+ BATCH_SIZE = 50 # Objects per API call (body_text CLOB has no 32KB limit)
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Hash helpers — espelham a logica de BAUCTX.FNC_BAU_STRIP_DUP_DDL_HEAD +
25
+ # RAWTOHEX(DBMS_CRYPTO.HASH(SOURCE, HASH_SH256)). Usados pelo --dry-run para
26
+ # comparar hashes locais com os do servidor SEM enviar nada.
27
+ # ---------------------------------------------------------------------------
28
+ def _strip_dup_ddl_head(source: str, obj_type: str) -> str:
29
+ """Remove o prefixo 'CREATE OR REPLACE <TYPE> [schema.]name\\n' duplicado
30
+ quando a linha seguinte ja comeca com '<TYPE> name'. Espelha
31
+ BAUCTX.FNC_BAU_STRIP_DUP_DDL_HEAD."""
32
+ if not source or not obj_type:
33
+ return source
34
+ pattern = (
35
+ r'^(CREATE\s+OR\s+REPLACE\s+' + re.escape(obj_type) +
36
+ r'\s+[^\n]+\n)\s*' + re.escape(obj_type) +
37
+ r'\s+("?[A-Za-z0-9_$#]+"?\.)?"?[A-Za-z0-9_$#]+"?\s+'
38
+ )
39
+ return re.sub(pattern, r'\1', source, count=1, flags=re.IGNORECASE)
40
+
41
+
42
+ def _compute_source_hash(source: str, obj_type: str) -> str:
43
+ """SHA-256 uppercase do source normalizado (mesma logica do servidor)."""
44
+ if not source:
45
+ return ""
46
+ normalized = _strip_dup_ddl_head(source, obj_type or "")
47
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest().upper()
48
+
49
+
50
+ def show_sync_preview(api: "BauApi", schema: str, objects: list, tables: list) -> dict:
51
+ """Compara hashes locais (calculados aqui) com os do servidor BAU.
52
+ Renderiza tabela rich agrupada por status (NEW / MODIFIED / UNCHANGED).
53
+ Retorna dict com contadores."""
54
+ # Fetch BAU side hashes (PL/SQL + tables)
55
+ try:
56
+ bau_obj = api.get_source_hashes(schema)
57
+ except Exception as e:
58
+ console.print(f" [red]Erro ao buscar hashes do BAU: {e}[/red]")
59
+ bau_obj = {"items": []}
60
+ try:
61
+ bau_tbl = api.get_table_hashes(schema)
62
+ except Exception as e:
63
+ console.print(f" [yellow]Aviso: hash de tabelas indisponível ({e})[/yellow]")
64
+ bau_tbl = {"items": []}
65
+
66
+ # Index by (type, name) — server returns items list
67
+ bau_obj_idx = {(it.get("type", "").upper(), it.get("name", "").upper()): it
68
+ for it in bau_obj.get("items", []) or []}
69
+ bau_tbl_idx = {(it.get("name", "").upper()): it
70
+ for it in bau_tbl.get("items", []) or []}
71
+
72
+ rows = []
73
+ counts = {"NEW": 0, "MODIFIED": 0, "UNCHANGED": 0, "TOTAL": 0}
74
+
75
+ # PL/SQL objects
76
+ for obj in objects:
77
+ name = (obj.get("name") or "").upper()
78
+ otype = (obj.get("type") or "").upper()
79
+ # Skip subprograms — server SOURCE table only tracks root objects
80
+ if obj.get("subprogram_name") or obj.get("parent"):
81
+ continue
82
+ source = obj.get("source") or ""
83
+ local_hash = _compute_source_hash(source, otype)
84
+ bau_item = bau_obj_idx.get((otype, name))
85
+ bau_hash = (bau_item or {}).get("hash", "") if bau_item else ""
86
+
87
+ if not bau_hash:
88
+ status = "NEW"
89
+ elif local_hash and local_hash == bau_hash:
90
+ status = "UNCHANGED"
91
+ else:
92
+ status = "MODIFIED"
93
+ rows.append({
94
+ "kind": "PL/SQL", "name": name, "type": otype,
95
+ "status": status, "local": local_hash[:16], "bau": bau_hash[:16],
96
+ })
97
+ counts[status] += 1
98
+ counts["TOTAL"] += 1
99
+
100
+ # Tables — no source_hash; rely on the table structure hash from server.
101
+ # Local side does not compute that here (it's a column-signature SHA at the
102
+ # server). We list each table as NEW (not in BAU) or UNCHANGED (in BAU)
103
+ # without per-byte comparison.
104
+ for tbl in tables:
105
+ name = (tbl.get("name") or "").upper()
106
+ bau_item = bau_tbl_idx.get(name)
107
+ status = "NEW" if not bau_item else "UNCHANGED"
108
+ rows.append({
109
+ "kind": "TABLE", "name": name, "type": "TABLE",
110
+ "status": status, "local": "(table)", "bau": "(table)",
111
+ })
112
+ counts[status] += 1
113
+ counts["TOTAL"] += 1
114
+
115
+ if not rows:
116
+ console.print(" [dim]Nenhum objeto extraído para comparar.[/dim]")
117
+ return counts
118
+
119
+ # Render
120
+ style_map = {
121
+ "NEW": "[green]NEW[/green]",
122
+ "MODIFIED": "[yellow]MODIFIED[/yellow]",
123
+ "UNCHANGED": "[dim]UNCHANGED[/dim]",
124
+ }
125
+ table = RichTable(
126
+ title=f"Sync preview — schema {schema} ({counts['TOTAL']} item(s))",
127
+ show_lines=False)
128
+ table.add_column("Kind", style="cyan")
129
+ table.add_column("Type")
130
+ table.add_column("Name")
131
+ table.add_column("Status")
132
+ table.add_column("Local hash")
133
+ table.add_column("BAU hash")
134
+ # Order: MODIFIED first, then NEW, then UNCHANGED
135
+ order = {"MODIFIED": 0, "NEW": 1, "UNCHANGED": 2}
136
+ rows.sort(key=lambda r: (order.get(r["status"], 9), r["kind"], r["name"]))
137
+ for r in rows:
138
+ table.add_row(r["kind"], r["type"], r["name"], style_map[r["status"]],
139
+ r["local"], r["bau"])
140
+ console.print(table)
141
+
142
+ summary = (
143
+ f" [bold]Preview:[/bold] "
144
+ f"[green]{counts['NEW']} NEW[/green]"
145
+ f" • [yellow]{counts['MODIFIED']} MODIFIED[/yellow]"
146
+ f" • [dim]{counts['UNCHANGED']} UNCHANGED[/dim]"
147
+ f" • total {counts['TOTAL']}"
148
+ )
149
+ console.print(summary)
150
+ return counts
151
+
152
+
153
+ def _resolve_wallet_password(name: str) -> str:
154
+ """Wallet password (thin-mode ewallet.pem) via env var then keyring.
155
+
156
+ Never persisted in config.json — env BAUCLI_WALLET_PASSWORD wins, else the
157
+ keyring entry '<db>::wallet' set by `baucli db add --wallet`.
158
+ """
159
+ import os
160
+ pw = os.environ.get("BAUCLI_WALLET_PASSWORD")
161
+ if pw:
162
+ return pw
163
+ if name:
164
+ try:
165
+ from baucli.config import get_db_password
166
+ return get_db_password(f"{name}::wallet")
167
+ except Exception:
168
+ return None
169
+ return None
170
+
171
+
172
+ def get_extractor(db_config: dict, password: str, name: str = None,
173
+ wallet_password: str = None):
174
+ """Factory: return the right extractor based on engine.
175
+
176
+ name (optional) is the DB alias — used to resolve the Oracle Wallet password
177
+ from the keyring when db_config carries a wallet_path. wallet_password, when
178
+ given, overrides that resolution (used during `connect` before the password
179
+ is persisted).
180
+ """
181
+ engine = db_config.get("engine", "").lower()
182
+
183
+ if engine == "oracle":
184
+ from baucli.db.oracle import OracleExtractor
185
+ wallet_path = db_config.get("wallet_path")
186
+ wpw = wallet_password if wallet_password is not None else (
187
+ _resolve_wallet_password(name) if wallet_path else None)
188
+ return OracleExtractor(
189
+ host=db_config.get("host"),
190
+ port=db_config.get("port"),
191
+ user=db_config["user"],
192
+ password=password,
193
+ service=db_config.get("service"),
194
+ wallet_path=wallet_path,
195
+ wallet_password=wpw,
196
+ )
197
+ elif engine == "postgresql":
198
+ from baucli.db.postgresql import PostgreSQLExtractor
199
+ return PostgreSQLExtractor(
200
+ host=db_config["host"],
201
+ port=db_config["port"],
202
+ user=db_config["user"],
203
+ password=password,
204
+ database=db_config.get("database"),
205
+ )
206
+ elif engine == "mssql":
207
+ from baucli.db.mssql import MSSQLExtractor
208
+ return MSSQLExtractor(
209
+ host=db_config["host"],
210
+ port=db_config["port"],
211
+ user=db_config["user"],
212
+ password=password,
213
+ database=db_config.get("database"),
214
+ )
215
+ elif engine == "mysql":
216
+ from baucli.db.mysql import MySQLExtractor
217
+ return MySQLExtractor(
218
+ host=db_config["host"],
219
+ port=db_config["port"],
220
+ user=db_config["user"],
221
+ password=password,
222
+ database=db_config.get("database"),
223
+ )
224
+ else:
225
+ raise ValueError(f"Unsupported database engine: {engine}")
226
+
227
+
228
+ def get_sync_recommendation(api: BauApi, schema: str) -> dict:
229
+ """Ask the BAU Server for sync recommendation.
230
+
231
+ Returns dict with: recommendation (FULL/DELTA), last_sync, delta_days, sync_max_days
232
+ """
233
+ try:
234
+ status = api.sync_status(schema)
235
+ # Handle wrapped response
236
+ if 'text' in status and 'recommendation' not in status:
237
+ import json
238
+ try:
239
+ status = json.loads(status['text'])
240
+ except Exception:
241
+ pass
242
+ return status
243
+ except BauApiError:
244
+ # Server unavailable or endpoint missing — default to FULL
245
+ return {"recommendation": "FULL", "last_sync": None, "delta_days": None, "sync_max_days": 30}
246
+
247
+
248
+ PARALLEL_THRESHOLD = 1000 # Default: parallelize if total objects+tables >= this
249
+ PARALLEL_WORKERS = 3 # Default thread count (from server config)
250
+
251
+
252
+ def sync_database(db_name: str, db_config: dict, api: BauApi,
253
+ schemas: list = None, force_full: bool = False,
254
+ days_override: int = None, sync_filter: str = "all",
255
+ parallel: bool = None, verbose: bool = False,
256
+ overwrite_undocumented: bool = False,
257
+ object_name: str = None, table_name: str = None,
258
+ dry_run: bool = False, scope_names: dict = None,
259
+ rowcount: bool = True) -> dict:
260
+ """Sync a single database: check status -> extract -> send to API.
261
+
262
+ Args:
263
+ db_name: Database alias
264
+ db_config: Database config dict
265
+ api: BauApi client
266
+ schemas: Override schema list
267
+ force_full: If True, always do full extraction
268
+ days_override: If set, extract only objects modified in last N days
269
+ sync_filter: 'all' (default), 'objects' (PL/SQL only), 'tables' (tables only)
270
+ parallel: True=force parallel, False=force sequential, None=auto (threshold-based)
271
+ verbose: Verbose output
272
+
273
+ Returns summary dict with counts.
274
+ """
275
+ password = get_db_password(db_name)
276
+ if not password:
277
+ import getpass
278
+ password = getpass.getpass(f" Password for '{db_name}': ")
279
+ if not password:
280
+ console.print(f" [red]No password provided.[/red]")
281
+ return {"status": "error", "message": "No password"}
282
+
283
+ engine = db_config.get("engine", "oracle").upper()
284
+ target_schemas = schemas or db_config.get("schemas", [])
285
+ if not target_schemas:
286
+ console.print(f" [yellow]No schemas configured for '{db_name}'. Add schemas to config.[/yellow]")
287
+ return {"status": "error", "message": "No schemas"}
288
+
289
+ extractor = get_extractor(db_config, password, db_name)
290
+ total_objects = 0
291
+ total_tables = 0
292
+
293
+ try:
294
+ extractor.connect()
295
+ info = extractor.test_connection()
296
+ live_db_version = info.get("version") or db_config.get("db_version")
297
+ if verbose:
298
+ console.print(f" Connected: {live_db_version or '?'}")
299
+
300
+ for schema in target_schemas:
301
+ # === Step 1: Decide FULL or DELTA ===
302
+ since_date = None
303
+ mode = "FULL"
304
+
305
+ # PostgreSQL: always FULL (no native DDL tracking, delta unreliable)
306
+ pg_forced_full = engine in ("postgresql", "postgres") and not force_full and days_override is None
307
+
308
+ if force_full:
309
+ mode = "FULL"
310
+ console.print(f" [cyan]{schema}[/cyan] — [bold]FULL[/bold] (forced)")
311
+ elif pg_forced_full:
312
+ mode = "FULL"
313
+ console.print(f" [cyan]{schema}[/cyan] — [bold]FULL[/bold] (PG: no DDL tracking, hash-based dedup)")
314
+ elif days_override is not None:
315
+ mode = "DELTA"
316
+ from datetime import datetime, timedelta
317
+ since_dt = datetime.now() - timedelta(days=days_override)
318
+ since_date = since_dt.strftime("%Y-%m-%dT%H:%M:%S")
319
+ console.print(f" [cyan]{schema}[/cyan] — [bold]DELTA[/bold] (last {days_override} days)")
320
+ else:
321
+ # Ask server for recommendation
322
+ rec = get_sync_recommendation(api, schema)
323
+ mode = rec.get("recommendation", "FULL")
324
+ last_sync = rec.get("last_sync")
325
+ delta_days = rec.get("delta_days")
326
+ sync_max = rec.get("sync_max_days", 30)
327
+
328
+ if mode == "DELTA" and last_sync:
329
+ since_date = last_sync
330
+ console.print(f" [cyan]{schema}[/cyan] — [bold]DELTA[/bold] (since {last_sync[:10]}, {delta_days}d ago, max={sync_max}d)")
331
+ else:
332
+ if last_sync is None:
333
+ console.print(f" [cyan]{schema}[/cyan] — [bold]FULL[/bold] (first sync)")
334
+ else:
335
+ console.print(f" [cyan]{schema}[/cyan] — [bold]FULL[/bold] (last sync {delta_days}d ago > max {sync_max}d)")
336
+
337
+ # === Step 2: Extract ===
338
+ objects = []
339
+ tables = []
340
+
341
+ # Scope pushdown: with a scope set, extract ONLY the scoped names.
342
+ # The Oracle extractor honors a collection in name_filter (IN list
343
+ # on the catalog query); other engines ignore it and fall back to
344
+ # the post-extraction filter below. Without this, a scoped sync of
345
+ # a 6000-table ERP still extracted everything before discarding.
346
+ eff_object_name = object_name
347
+ eff_table_name = table_name
348
+ if scope_names is not None:
349
+ if not eff_object_name:
350
+ eff_object_name = sorted(scope_names.get("plsql") or set())
351
+ if not eff_table_name:
352
+ eff_table_name = sorted(scope_names.get("table") or set())
353
+
354
+ # Determine if parallel extraction should be used
355
+ use_parallel = False
356
+ par_workers = PARALLEL_WORKERS
357
+ if parallel is True:
358
+ use_parallel = True
359
+ elif parallel is False:
360
+ use_parallel = False
361
+ else:
362
+ try:
363
+ rec = get_sync_recommendation(api, schema)
364
+ threshold = int(rec.get("parallel_threshold", PARALLEL_THRESHOLD))
365
+ par_enabled = rec.get("parallel_enabled", "Y")
366
+ par_workers = int(rec.get("parallel_workers", PARALLEL_WORKERS))
367
+ total_hint = int(rec.get("objects_count", 0)) + int(rec.get("tables_count", 0))
368
+ if par_enabled == "Y":
369
+ if mode == "FULL":
370
+ use_parallel = True
371
+ else:
372
+ use_parallel = total_hint >= threshold
373
+ except Exception:
374
+ use_parallel = mode == "FULL"
375
+
376
+ if use_parallel and sync_filter == "all":
377
+ from concurrent.futures import ThreadPoolExecutor
378
+ import threading, time
379
+
380
+ # Quick count before extracting (1 if name filter set, else schema count)
381
+ cnt_obj = -1
382
+ cnt_tbl = -1
383
+ try:
384
+ cnt_obj = (1 if object_name
385
+ else len(eff_object_name) if isinstance(eff_object_name, list)
386
+ else extractor.count_objects(schema))
387
+ cnt_tbl = (1 if table_name
388
+ else len(eff_table_name) if isinstance(eff_table_name, list)
389
+ else extractor.count_tables(schema))
390
+ if cnt_obj >= 0 and cnt_tbl >= 0:
391
+ console.print(f" Scope: [cyan]{cnt_obj}[/cyan] objects + [cyan]{cnt_tbl}[/cyan] tables/views")
392
+ except Exception:
393
+ pass
394
+
395
+ status_lock = threading.Lock()
396
+ obj_status = ["starting..."]
397
+ tbl_status = ["starting..."]
398
+ obj_times = []
399
+ tbl_times = []
400
+
401
+ def _obj_progress(current, total, name):
402
+ with status_lock:
403
+ eta = ""
404
+ if len(obj_times) >= 5:
405
+ avg = sum(obj_times) / len(obj_times)
406
+ remaining = total - current
407
+ eta_s = int(remaining * avg)
408
+ eta = f" ETA {eta_s//60}m{eta_s%60:02d}s" if eta_s >= 60 else f" ETA {eta_s}s"
409
+ obj_status[0] = f"{current}/{total} {name[:40]}{eta}"
410
+
411
+ def _tbl_progress(current, total, name):
412
+ with status_lock:
413
+ eta = ""
414
+ if len(tbl_times) >= 5:
415
+ avg = sum(tbl_times) / len(tbl_times)
416
+ remaining = total - current
417
+ eta_s = int(remaining * avg)
418
+ eta = f" ETA {eta_s//60}m{eta_s%60:02d}s" if eta_s >= 60 else f" ETA {eta_s}s"
419
+ tbl_status[0] = f"{current}/{total} {name[:40]}{eta}"
420
+
421
+ def _extract_objects():
422
+ last_t = [time.time()]
423
+ def _cb(c, t, n):
424
+ now = time.time()
425
+ dt = now - last_t[0]
426
+ last_t[0] = now
427
+ if c > 1:
428
+ obj_times.append(dt)
429
+ _obj_progress(c, t, n)
430
+ # Own connection for thread safety
431
+ ext2 = get_extractor(db_config, password, db_name)
432
+ ext2.connect()
433
+ with status_lock:
434
+ obj_status[0] = "connected, querying..."
435
+ try:
436
+ r = ext2.extract_objects(schema, since_date=since_date, on_progress=_cb,
437
+ name_filter=eff_object_name)
438
+ with status_lock:
439
+ obj_status[0] = f"[green]done[/green] ({len(r)})"
440
+ return r
441
+ finally:
442
+ ext2.disconnect()
443
+
444
+ def _extract_tables():
445
+ last_t = [time.time()]
446
+ def _cb(c, t, n):
447
+ now = time.time()
448
+ dt = now - last_t[0]
449
+ last_t[0] = now
450
+ if c > 1:
451
+ tbl_times.append(dt)
452
+ _tbl_progress(c, t, n)
453
+ # Own connection for thread safety
454
+ ext2 = get_extractor(db_config, password, db_name)
455
+ ext2.connect()
456
+ with status_lock:
457
+ tbl_status[0] = "connected, querying..."
458
+ try:
459
+ r = ext2.extract_tables(schema, since_date=since_date, on_progress=_cb,
460
+ name_filter=eff_table_name)
461
+ with status_lock:
462
+ tbl_status[0] = f"[green]done[/green] ({len(r)})"
463
+ return r
464
+ finally:
465
+ ext2.disconnect()
466
+
467
+ with Progress(
468
+ SpinnerColumn(), TextColumn("{task.description}"), TimeElapsedColumn(),
469
+ console=console, transient=False,
470
+ ) as progress:
471
+ tid_obj = progress.add_task(" PL/SQL: extracting...", total=None)
472
+ tid_tbl = progress.add_task(" Tables: extracting...", total=None)
473
+
474
+ with ThreadPoolExecutor(max_workers=min(par_workers, 2)) as pool:
475
+ fut_obj = pool.submit(_extract_objects)
476
+ fut_tbl = pool.submit(_extract_tables)
477
+
478
+ while not (fut_obj.done() and fut_tbl.done()):
479
+ with status_lock:
480
+ progress.update(tid_obj, description=f" PL/SQL: {obj_status[0]}")
481
+ progress.update(tid_tbl, description=f" Tables: {tbl_status[0]}")
482
+ progress.refresh()
483
+ time.sleep(0.3)
484
+
485
+ # Final update
486
+ with status_lock:
487
+ progress.update(tid_obj, description=f" PL/SQL: {obj_status[0]}")
488
+ progress.update(tid_tbl, description=f" Tables: {tbl_status[0]}")
489
+
490
+ try:
491
+ objects = fut_obj.result()
492
+ except Exception as e:
493
+ console.print(f" [red]Objects error: {e}[/red]")
494
+ try:
495
+ tables = fut_tbl.result()
496
+ except Exception as e:
497
+ console.print(f" [red]Tables error: {e}[/red]")
498
+
499
+ else:
500
+ # Sequential — show count + progress per item (1 if name filter set)
501
+ cnt_obj = -1
502
+ cnt_tbl = -1
503
+ try:
504
+ if sync_filter in ("all", "objects"):
505
+ cnt_obj = (1 if object_name
506
+ else len(eff_object_name) if isinstance(eff_object_name, list)
507
+ else extractor.count_objects(schema))
508
+ if sync_filter in ("all", "tables"):
509
+ cnt_tbl = (1 if table_name
510
+ else len(eff_table_name) if isinstance(eff_table_name, list)
511
+ else extractor.count_tables(schema))
512
+ parts = []
513
+ if cnt_obj >= 0: parts.append(f"[cyan]{cnt_obj}[/cyan] objects")
514
+ if cnt_tbl >= 0: parts.append(f"[cyan]{cnt_tbl}[/cyan] tables/views")
515
+ if parts:
516
+ console.print(f" Scope: {' + '.join(parts)}")
517
+ except Exception:
518
+ pass
519
+
520
+ import time as _time
521
+ with Progress(
522
+ SpinnerColumn(), TextColumn("{task.description}"), TimeElapsedColumn(),
523
+ console=console, transient=False,
524
+ ) as progress:
525
+ if sync_filter in ("all", "objects"):
526
+ total_est = cnt_obj if cnt_obj > 0 else None
527
+ tid = progress.add_task(f" PL/SQL: extracting...", total=total_est)
528
+ item_times = []
529
+ last_t = [_time.time()]
530
+ def _obj_cb(c, t, n):
531
+ now = _time.time()
532
+ dt = now - last_t[0]
533
+ last_t[0] = now
534
+ if c > 1:
535
+ item_times.append(dt)
536
+ eta = ""
537
+ if len(item_times) >= 5:
538
+ avg = sum(item_times) / len(item_times)
539
+ rem = t - c
540
+ eta_s = int(rem * avg)
541
+ eta = f" ETA {eta_s//60}m{eta_s%60:02d}s" if eta_s >= 60 else f" ETA {eta_s}s"
542
+ progress.update(tid, completed=c, description=f" PL/SQL: {c}/{t} {n[:40]}{eta}")
543
+ objects = extractor.extract_objects(schema, since_date=since_date, on_progress=_obj_cb,
544
+ name_filter=eff_object_name)
545
+ progress.update(tid, description=f" PL/SQL: [green]done[/green] ({len(objects)})", completed=total_est or len(objects))
546
+
547
+ if sync_filter in ("all", "tables"):
548
+ total_est = cnt_tbl if cnt_tbl > 0 else None
549
+ tid = progress.add_task(f" Tables: extracting...", total=total_est)
550
+ item_times = []
551
+ last_t = [_time.time()]
552
+ def _tbl_cb(c, t, n):
553
+ now = _time.time()
554
+ dt = now - last_t[0]
555
+ last_t[0] = now
556
+ if c > 1:
557
+ item_times.append(dt)
558
+ eta = ""
559
+ if len(item_times) >= 5:
560
+ avg = sum(item_times) / len(item_times)
561
+ rem = t - c
562
+ eta_s = int(rem * avg)
563
+ eta = f" ETA {eta_s//60}m{eta_s%60:02d}s" if eta_s >= 60 else f" ETA {eta_s}s"
564
+ progress.update(tid, completed=c, description=f" Tables: {c}/{t} {n[:40]}{eta}")
565
+ tables = extractor.extract_tables(schema, since_date=since_date, on_progress=_tbl_cb,
566
+ name_filter=eff_table_name)
567
+ progress.update(tid, description=f" Tables: [green]done[/green] ({len(tables)})", completed=total_est or len(tables))
568
+
569
+ mode_label = "[green]parallel[/green]" if use_parallel and sync_filter == "all" else "sequential"
570
+
571
+ # === Scope filter: keep only objects listed in the scope set ===
572
+ # Client-side recorte — the whole motivation of scope sets: a
573
+ # migration project touches ~10 of an ERP's hundreds of objects, so
574
+ # never extract/send the rest (saves catalog noise + AI tokens).
575
+ if scope_names is not None:
576
+ scope_pl = scope_names.get("plsql") or set()
577
+ scope_tab = scope_names.get("table") or set()
578
+ before_o, before_t = len(objects), len(tables)
579
+ objects = [o for o in objects
580
+ if (o.get("name") or "").upper() in scope_pl]
581
+ tables = [t for t in tables
582
+ if (t.get("name") or "").upper() in scope_tab]
583
+ console.print(
584
+ f" [magenta]scope[/magenta]: {len(objects)}/{before_o} PL/SQL + "
585
+ f"{len(tables)}/{before_t} tabelas dentro do recorte "
586
+ f"(demais ignorados)")
587
+
588
+ all_items = objects + tables
589
+
590
+ if not all_items:
591
+ label = "modified " if mode == "DELTA" else ""
592
+ console.print(f" No {label}objects found in {schema}")
593
+ # Hint when --object was used but the name might actually be a table.
594
+ if object_name and not table_name:
595
+ console.print(f" [dim]Hint: '{object_name}' is not a PL/SQL object. "
596
+ f"If it's a table, try: baucli sync --table {object_name}[/dim]")
597
+ elif table_name and not object_name:
598
+ console.print(f" [dim]Hint: '{table_name}' is not a table/view. "
599
+ f"If it's a PL/SQL object, try: baucli sync --object {table_name}[/dim]")
600
+ continue
601
+
602
+ obj_count = len(objects)
603
+ tbl_count = len(tables)
604
+ console.print(f" Found: {obj_count} PL/SQL + {tbl_count} tables ({mode_label})")
605
+
606
+ # === Dry-run: preview hash diff sem enviar ao servidor ===
607
+ if dry_run:
608
+ console.print(f" [bold yellow]DRY-RUN[/bold yellow] — nenhum dado sera enviado ao servidor.")
609
+ preview = show_sync_preview(api, schema, objects, tables)
610
+ total_objects += obj_count
611
+ total_tables += tbl_count
612
+ continue # pula o envio para este schema
613
+
614
+ # === Step 3: Send to API in batches ===
615
+ synced_obj = 0
616
+ synced_tbl = 0
617
+ synced_src = 0
618
+ rebaselined = 0 # post-deploy idempotent re-baselines on the server
619
+ server_errors = 0
620
+ error_details: dict[str, str] = {} # {object_name: server SQLERRM}
621
+ total_batches = (len(all_items) + BATCH_SIZE - 1) // BATCH_SIZE
622
+ import time as _time
623
+ batch_times = [] # seconds per batch for ETA calculation
624
+ SAMPLE_SIZE = 4 # batches to sample before showing ETA
625
+
626
+ with Progress(
627
+ SpinnerColumn(),
628
+ TextColumn("[bold]{task.description}"),
629
+ BarColumn(bar_width=30),
630
+ MofNCompleteColumn(),
631
+ TextColumn("•"),
632
+ TimeElapsedColumn(),
633
+ console=console,
634
+ ) as progress:
635
+ task = progress.add_task(
636
+ f" Syncing {schema}", total=total_batches)
637
+
638
+ consecutive_errors = 0
639
+ MAX_CONSECUTIVE_ERRORS = 3
640
+ MAX_RETRIES = 2
641
+
642
+ for i in range(0, len(all_items), BATCH_SIZE):
643
+ batch = all_items[i:i + BATCH_SIZE]
644
+ batch_num = (i // BATCH_SIZE) + 1
645
+ batch_ok = False
646
+ t0 = _time.time()
647
+
648
+ for attempt in range(MAX_RETRIES + 1):
649
+ try:
650
+ result = api.sync_batch(
651
+ schema=schema,
652
+ db_engine=engine,
653
+ objects=batch,
654
+ db_version=live_db_version,
655
+ overwrite_undocumented=overwrite_undocumented,
656
+ db_name=db_name,
657
+ )
658
+ synced_obj += result.get('objects_synced', 0)
659
+ synced_tbl += result.get('tables_synced', 0)
660
+ synced_src += result.get('sources_stored', 0)
661
+ rebaselined += result.get('sources_rebaselined', 0)
662
+ # Server-side per-object failures: PROCESS_SYNC_BATCH
663
+ # swallows them into `errors` + `error_details` and
664
+ # still returns 200. If we don't surface this, the
665
+ # operator sees "Synced: 0 objects" with no clue why
666
+ # while side-effects (e.g. PRE_DOCUMENTED flips) did
667
+ # happen inside the silent EXCEPTION blocks.
668
+ batch_errs = int(result.get('errors', 0) or 0)
669
+ if batch_errs:
670
+ server_errors += batch_errs
671
+ details = result.get('error_details') or {}
672
+ if isinstance(details, dict):
673
+ error_details.update({k: str(v) for k, v in details.items()})
674
+ batch_ok = True
675
+ consecutive_errors = 0
676
+ break
677
+ except BauApiError as e:
678
+ err_msg = str(e)
679
+ if attempt < MAX_RETRIES:
680
+ wait = 2 ** attempt
681
+ progress.update(task, description=f" Retry {attempt+1}/{MAX_RETRIES} in {wait}s...")
682
+ _time.sleep(wait)
683
+ else:
684
+ console.print(f" [red]Batch {batch_num} failed after {MAX_RETRIES+1} attempts: {err_msg}[/red]")
685
+
686
+ if batch_ok:
687
+ elapsed = _time.time() - t0
688
+ batch_times.append(elapsed)
689
+
690
+ if not batch_ok:
691
+ consecutive_errors += 1
692
+ if consecutive_errors >= MAX_CONSECUTIVE_ERRORS:
693
+ console.print(f" [red]Circuit breaker: {MAX_CONSECUTIVE_ERRORS} consecutive failures. Stopping sync.[/red]")
694
+ console.print(f" [yellow]Synced so far: {synced_obj} objects, {synced_tbl} tables. Resume with: baucli sync --db {db_name}[/yellow]")
695
+ break
696
+
697
+ # Build description with ETA
698
+ desc = f" Syncing {schema} ({synced_obj} obj, {synced_tbl} tbl)"
699
+ if len(batch_times) >= SAMPLE_SIZE:
700
+ avg_batch = sum(batch_times) / len(batch_times)
701
+ remaining = total_batches - batch_num
702
+ eta_sec = int(remaining * avg_batch)
703
+ if eta_sec >= 60:
704
+ desc += f" • ETA {eta_sec // 60}m{eta_sec % 60:02d}s"
705
+ elif eta_sec > 0:
706
+ desc += f" • ETA {eta_sec}s"
707
+
708
+ progress.update(task, advance=1, description=desc)
709
+
710
+ # Show timing summary
711
+ if batch_times:
712
+ total_time = sum(batch_times)
713
+ avg_per_item = total_time / max(len(all_items), 1)
714
+ items_per_sec = len(all_items) / max(total_time, 0.01)
715
+ rebaseline_str = (
716
+ f" [cyan](re-baselined {rebaselined})[/cyan]"
717
+ if rebaselined else ""
718
+ )
719
+ console.print(
720
+ f" [green]Synced: {synced_obj} objects, {synced_tbl} tables, "
721
+ f"{synced_src} sources[/green]{rebaseline_str}"
722
+ f" [dim]({total_time:.1f}s, {avg_per_item:.2f}s/item, {items_per_sec:.0f} items/s)[/dim]"
723
+ )
724
+ if server_errors:
725
+ # The server replied 200 but logged per-object failures.
726
+ # Show the count plus a short sample so the operator can
727
+ # see what actually broke without digging the server log.
728
+ console.print(
729
+ f" [yellow]Server reported {server_errors} per-object error(s) "
730
+ f"during PROCESS_SYNC_BATCH.[/yellow]"
731
+ )
732
+ for i, (obj_name, err) in enumerate(list(error_details.items())[:5]):
733
+ console.print(f" [yellow]•[/yellow] {obj_name}: [dim]{err}[/dim]")
734
+ if len(error_details) > 5:
735
+ console.print(f" [dim]... and {len(error_details) - 5} more[/dim]")
736
+ # === Dependency graph (Fase 2): extrai as dependências do dicionário
737
+ # do CLIENTE (ALL/DBA_DEPENDENCIES, escopo intra-schema) e envia numa
738
+ # chamada dedicada (objects=[]). O servidor persiste em BAU_DEP_RAW e
739
+ # o grafo é reconstruído por PKG_BAU_DEPGRAPH — o BAU nunca lê o
740
+ # dicionário local. Só Oracle, modo objetos, fora do single-object.
741
+ if engine.upper() == "ORACLE" and sync_filter in ("all", "objects") \
742
+ and not object_name and hasattr(extractor, "extract_dependencies"):
743
+ try:
744
+ deps = extractor.extract_dependencies(schema)
745
+ api.sync_batch(schema=schema, db_engine=engine, objects=[],
746
+ dependencies=deps, db_version=live_db_version,
747
+ db_name=db_name)
748
+ console.print(f" [green]Dependencies: {len(deps)} edge(s) sent[/green]")
749
+ except Exception as e:
750
+ console.print(f" [yellow]Dependencies skip: {e}[/yellow]")
751
+
752
+ # === DOCSEQ: sequences do schema (chamada dedicada, objects=[]) ===
753
+ # O servidor persiste os metadados fisicos (SYNC_FROM_JSON) e roda
754
+ # PKG_BAU_DOCSEQ.DISCOVER — que liga sequence->tabela a partir dos
755
+ # defaults/triggers ja sincronizados. Todos os engines com extractor
756
+ # que expoe extract_sequences (MySQL devolve [] — usa AUTO_INCREMENT).
757
+ # Fora do single-object/single-table e do recorte por scope.
758
+ if sync_filter in ("all", "tables") and not object_name and not table_name \
759
+ and scope_names is None and hasattr(extractor, "extract_sequences"):
760
+ try:
761
+ # Spinner durante a coleta + envio (feedback consistente com
762
+ # PL/SQL/Tables): mostra o avanco e a contagem de sequences.
763
+ with console.status(" Sequences: extracting...", spinner="dots"):
764
+ seqs = extractor.extract_sequences(schema)
765
+ if seqs:
766
+ with console.status(f" Sequences: sending {len(seqs)}...", spinner="dots"):
767
+ api.sync_batch(schema=schema, db_engine=engine, objects=[],
768
+ sequences=seqs, db_version=live_db_version,
769
+ db_name=db_name)
770
+ console.print(f" Sequences: [green]done[/green] ({len(seqs)} collected & sent)")
771
+ else:
772
+ console.print(f" Sequences: [dim]none[/dim]")
773
+ except Exception as e:
774
+ console.print(f" [yellow]Sequences skip: {e}[/yellow]")
775
+
776
+ total_objects += obj_count
777
+ total_tables += tbl_count
778
+
779
+ # Baseline de num_rows (a "ideia" do tamanho das tabelas) capturada em
780
+ # TODO sync, com origem DEV (o baucli normalmente roda no ambiente de
781
+ # dev). Best-effort — nunca derruba o sync. No servidor, um profile PROD
782
+ # é "grudento" e não é sobrescrito por este baseline de DEV.
783
+ if rowcount and not dry_run:
784
+ # Scope pushdown: com scope set, o snapshot de num_rows cobre SO as
785
+ # tabelas do recorte (sem ele, um ERP de milhares de tabelas ia
786
+ # inteiro para o /profile/ingest a cada sync).
787
+ _rc_filter = None
788
+ if scope_names is not None:
789
+ _rc_filter = sorted(scope_names.get("table") or set())
790
+ for _rc_schema in target_schemas:
791
+ try:
792
+ if _rc_filter is not None:
793
+ _rc = extractor.extract_row_counts(_rc_schema, name_filter=_rc_filter)
794
+ else:
795
+ _rc = extractor.extract_row_counts(_rc_schema)
796
+ api.profile_ingest(_rc)
797
+ console.print(f" Row counts: [green]done[/green] "
798
+ f"({len(_rc.get('tables', []))} tables, source=DEV"
799
+ f"{', recorte' if _rc_filter is not None else ''})")
800
+ except Exception as e:
801
+ if verbose:
802
+ console.print(f" [dim]row-counts skip ({_rc_schema}): {e}[/dim]")
803
+
804
+ except BauConnectionError as e:
805
+ # Expected failure mode — banco fora do ar, credencial inválida, etc.
806
+ # Mostra só a mensagem amigável + hint. Sem traceback no fluxo normal.
807
+ console.print(f" [red]✗ {e.message}[/red]")
808
+ if e.hint:
809
+ console.print(f" [yellow]→ {e.hint}[/yellow]")
810
+ if verbose and e.cause is not None:
811
+ import traceback
812
+ console.print(f" [dim]{''.join(traceback.format_exception(type(e.cause), e.cause, e.cause.__traceback__))}[/dim]")
813
+ return {"status": "error", "message": e.message, "error_type": "connection"}
814
+ except Exception as e:
815
+ import traceback
816
+ console.print(f" [red]Connection error: {e}[/red]")
817
+ console.print(f" [dim]{traceback.format_exc()}[/dim]")
818
+ return {"status": "error", "message": str(e)}
819
+ finally:
820
+ extractor.disconnect()
821
+
822
+ return {
823
+ "status": "ok",
824
+ "db_name": db_name,
825
+ "engine": engine,
826
+ "objects": total_objects,
827
+ "tables": total_tables,
828
+ "schemas": target_schemas,
829
+ }
830
+
831
+
832
+ def sync_all(api: BauApi, force_full: bool = False,
833
+ days_override: int = None, sync_filter: str = "all",
834
+ verbose: bool = False,
835
+ overwrite_undocumented: bool = False) -> list:
836
+ """Sync all configured databases."""
837
+ cfg = load_config()
838
+ results = []
839
+ for name, db in cfg.get("databases", {}).items():
840
+ console.print(f"\n[bold]{name}[/bold] ({db.get('engine', '?')})")
841
+ result = sync_database(name, db, api, force_full=force_full,
842
+ days_override=days_override, sync_filter=sync_filter,
843
+ verbose=verbose,
844
+ overwrite_undocumented=overwrite_undocumented)
845
+ results.append(result)
846
+ return results