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/__init__.py +8 -0
- baucli/apex_rebuild.py +1458 -0
- baucli/apexlang.py +317 -0
- baucli/api.py +914 -0
- baucli/cli.py +7754 -0
- baucli/config.py +244 -0
- baucli/db/__init__.py +117 -0
- baucli/db/mssql.py +443 -0
- baucli/db/mysql.py +388 -0
- baucli/db/oracle.py +1294 -0
- baucli/db/postgresql.py +482 -0
- baucli/docmig.py +149 -0
- baucli/git.py +214 -0
- baucli/legacy/__init__.py +9 -0
- baucli/legacy/delphi.py +365 -0
- baucli/mcp_server.py +657 -0
- baucli/project.py +360 -0
- baucli/rag_embed.py +26 -0
- baucli/sample_scan.py +105 -0
- baucli/scope_scan.py +272 -0
- baucli/screenshot.py +316 -0
- baucli/seed_check.py +190 -0
- baucli/stress.py +409 -0
- baucli/sync.py +846 -0
- baucli-1.0.1.dist-info/METADATA +179 -0
- baucli-1.0.1.dist-info/RECORD +29 -0
- baucli-1.0.1.dist-info/WHEEL +5 -0
- baucli-1.0.1.dist-info/entry_points.txt +2 -0
- baucli-1.0.1.dist-info/top_level.txt +1 -0
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
|