dbbridgekit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ """Migração de DADOS: copia linhas de uma conexão origem pra uma conexão destino, tabela por tabela,
2
+ em lotes. Não infere schema aqui — recebe a lista de tabelas (normalmente as mesmas que
3
+ `schema_migration.py` acabou de criar no destino) e usa introspecção simples via SQL padrão
4
+ (SELECT * FROM tabela) pra descobrir as colunas de cada lado.
5
+
6
+ Nunca faz DROP/TRUNCATE sozinho — se a tabela destino já tem linha, ver `on_conflict` (default:
7
+ 'append', nunca sobrescreve sem pedir)."""
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+
13
+ @dataclass
14
+ class TableMigrationResult:
15
+ table: str
16
+ rows_copied: int
17
+ source_count: int
18
+ target_count: int
19
+
20
+ @property
21
+ def counts_match(self) -> bool:
22
+ return self.source_count == self.target_count
23
+
24
+
25
+ @dataclass
26
+ class DataMigrationResult:
27
+ tables: list[TableMigrationResult] = field(default_factory=list)
28
+
29
+ @property
30
+ def total_rows(self) -> int:
31
+ return sum(t.rows_copied for t in self.tables)
32
+
33
+ @property
34
+ def all_counts_match(self) -> bool:
35
+ return all(t.counts_match for t in self.tables)
36
+
37
+
38
+ def _table_columns(connection, table: str) -> list[str]:
39
+ """Introspecção genérica via SELECT com LIMIT 0 — funciona em qualquer dialeto que devolva
40
+ `cursor.description` (sqlite3 e psycopg fazem), sem precisar de PRAGMA/information_schema
41
+ específico de dialeto pra esta finalidade pontual."""
42
+ cur = connection.execute(f"SELECT * FROM {table} WHERE 1=0")
43
+ return [d[0] if not hasattr(d, "name") else d.name for d in cur.description]
44
+
45
+
46
+ def migrate_table_data(source_connection, target_connection, table: str, batch_size: int = 500) -> TableMigrationResult:
47
+ columns = _table_columns(source_connection, table)
48
+ col_list = ",".join(columns)
49
+ placeholders = ",".join(["?"] * len(columns))
50
+
51
+ cur = source_connection.execute(f"SELECT {col_list} FROM {table}")
52
+ copied = 0
53
+ while True:
54
+ rows = cur.fetchmany(batch_size) if hasattr(cur, "fetchmany") else cur.fetchall()
55
+ if not rows:
56
+ break
57
+ for row in rows:
58
+ values = tuple(row[c] if hasattr(row, "keys") else row[i] for i, c in enumerate(columns))
59
+ target_connection.execute(f"INSERT INTO {table}({col_list}) VALUES({placeholders})", values)
60
+ copied += 1
61
+ if not hasattr(cur, "fetchmany"):
62
+ break
63
+ if hasattr(target_connection, "commit"):
64
+ target_connection.commit()
65
+
66
+ source_count = source_connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
67
+ target_count = target_connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
68
+
69
+ return TableMigrationResult(table=table, rows_copied=copied, source_count=source_count, target_count=target_count)
70
+
71
+
72
+ def migrate_data(source_connection, target_connection, tables: list[str], batch_size: int = 500) -> DataMigrationResult:
73
+ result = DataMigrationResult()
74
+ for table in tables:
75
+ result.tables.append(migrate_table_data(source_connection, target_connection, table, batch_size))
76
+ return result
@@ -0,0 +1,57 @@
1
+ """Rollback: desfaz mudanças de CÓDIGO aplicadas pelo codemod (via backup automático — ver
2
+ codemod/codemod.py, que já salva uma cópia de cada arquivo ANTES de escrever). Rollback de DADOS/
3
+ SCHEMA é dialeto-específico de propósito — a forma correta de reverter um schema/dado já migrado é
4
+ restaurar o backup do BANCO ORIGEM (nunca tentar "desfazer" writes já commitados no destino via SQL
5
+ reverso, que é frágil e pode mascarar erro). Ver README.md pra orientação de backup do banco antes
6
+ de qualquer `migrate-data`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from pathlib import Path
12
+
13
+
14
+ def rollback_code_changes(project_root: Path, backup_dir: Path | None = None) -> int:
15
+ """Restaura cada arquivo Python a partir do backup MAIS ANTIGO salvo em `backup_dir` (o estado
16
+ de antes da primeira mudança aplicada nesta sessão de codemod) — mesma lógica e mesmo diretório
17
+ que `codemod.apply_changes()` já usa pra criar os backups."""
18
+ import shutil
19
+
20
+ backup_dir = backup_dir or (Path(project_root) / ".dbbridge_backups")
21
+ if not backup_dir.exists():
22
+ return 0
23
+
24
+ by_original: dict[str, list[Path]] = defaultdict(list)
25
+ for backup in backup_dir.glob("*.bak"):
26
+ original_name = backup.name.split(".")[0] + ".py"
27
+ by_original[original_name].append(backup)
28
+
29
+ restored = 0
30
+ for original_name, backups in by_original.items():
31
+ oldest = sorted(backups, key=lambda p: p.stat().st_mtime)[0]
32
+ matches = list(Path(project_root).rglob(original_name))
33
+ if matches:
34
+ shutil.copy2(oldest, matches[0])
35
+ restored += 1
36
+ return restored
37
+
38
+
39
+ class DatabaseSnapshot:
40
+ """Ponto de extensão pra rollback de BANCO — deliberadamente sem implementação concreta de
41
+ dialeto aqui (backup de SQLite via `.backup()` nativo, backup de Postgres via `pg_dump`, cada
42
+ um com ferramentas/permissões próprias — ver scripts equivalentes já usados em produção nesta
43
+ mesma base de código, `backend/scripts/backup_db.py` e `migrate_to_postgres.py`, como
44
+ referência de padrão a seguir quando isso for implementado de verdade pra cada dialeto)."""
45
+
46
+ def __init__(self, dialect: str):
47
+ self.dialect = dialect
48
+
49
+ def create(self, dsn: str, dest_path: Path) -> None:
50
+ raise NotImplementedError(
51
+ f"Snapshot de banco pro dialeto '{self.dialect}' ainda não implementado — "
52
+ "faça backup manual antes de rodar migrate-data (ver README.md).")
53
+
54
+ def restore(self, dsn: str, snapshot_path: Path) -> None:
55
+ raise NotImplementedError(
56
+ f"Restore de banco pro dialeto '{self.dialect}' ainda não implementado — "
57
+ "restaure o backup manual feito antes da migração.")
@@ -0,0 +1,56 @@
1
+ """Migração de SCHEMA: pega o Schema (IR) já parseado da origem, renderiza pro destino, e aplica
2
+ (executa o DDL gerado) contra uma conexão real do destino. Separado de data_migration.py de
3
+ propósito — schema primeiro (Fase 1), dados depois (Fase 4), sempre nessa ordem."""
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+
8
+ from ..core.parser import get_parser
9
+ from ..core.renderer import get_renderer
10
+ from ..core.sql_lex import split_statements
11
+
12
+
13
+ @dataclass
14
+ class SchemaMigrationResult:
15
+ sql: str
16
+ statements_executed: int
17
+ review_required_count: int
18
+ findings: list[str] = field(default_factory=list)
19
+
20
+
21
+ def render_target_ddl(source_sql: str, source_dialect: str, target_dialect: str) -> tuple[str, list[str]]:
22
+ """Só a etapa de tradução (parser origem -> IR -> renderer destino) — não toca em banco nenhum.
23
+ Separado de `apply_schema` pra permitir revisar o SQL gerado antes de rodar de verdade."""
24
+ parser = get_parser(source_dialect)
25
+ renderer = get_renderer(target_dialect)
26
+ parse_result = parser.parse(source_sql)
27
+ render_result = renderer.render_schema(parse_result.schema)
28
+
29
+ findings = [f"parse: {f.reason}" for f in parse_result.findings]
30
+ findings += [f"render: {f.reason}" for f in render_result.findings]
31
+ return render_result.sql, findings
32
+
33
+
34
+ def apply_schema(target_connection, target_sql: str) -> int:
35
+ """Executa o DDL renderizado contra uma conexão de verdade do destino (objeto com `.execute()`
36
+ estilo sqlite3/psycopg/DBBridge compat). Devolve quantos statements rodaram. Nunca faz DROP
37
+ nem TRUNCATE — só CREATE (o schema_migration é aditivo, igual init_db() em qualquer app real)."""
38
+ count = 0
39
+ for stmt in split_statements(target_sql):
40
+ target_connection.execute(stmt)
41
+ count += 1
42
+ if hasattr(target_connection, "commit"):
43
+ target_connection.commit()
44
+ return count
45
+
46
+
47
+ def migrate_schema(source_sql: str, source_dialect: str, target_dialect: str, target_connection=None) -> SchemaMigrationResult:
48
+ """Fluxo completo: traduz e (se `target_connection` for passado) aplica. Sem conexão, só devolve
49
+ o SQL traduzido pra revisão (dry-run implícito)."""
50
+ target_sql, findings = render_target_ddl(source_sql, source_dialect, target_dialect)
51
+ executed = 0
52
+ if target_connection is not None:
53
+ executed = apply_schema(target_connection, target_sql)
54
+ return SchemaMigrationResult(
55
+ sql=target_sql, statements_executed=executed,
56
+ review_required_count=len(findings), findings=findings)
@@ -0,0 +1,85 @@
1
+ """Validação pós-migração: contagem de linhas (sempre) e checksum por tabela (quando possível) —
2
+ prova que os dados migrados são os mesmos, não só "a mesma quantidade"."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class TableValidation:
11
+ table: str
12
+ source_count: int
13
+ target_count: int
14
+ source_checksum: str | None
15
+ target_checksum: str | None
16
+
17
+ @property
18
+ def counts_match(self) -> bool:
19
+ return self.source_count == self.target_count
20
+
21
+ @property
22
+ def checksums_match(self) -> bool | None:
23
+ if self.source_checksum is None or self.target_checksum is None:
24
+ return None # não calculado (ex: tabela grande demais, ver skip_checksum_above)
25
+ return self.source_checksum == self.target_checksum
26
+
27
+ @property
28
+ def ok(self) -> bool:
29
+ return self.counts_match and (self.checksums_match is not False)
30
+
31
+
32
+ @dataclass
33
+ class ValidationReport:
34
+ tables: list[TableValidation] = field(default_factory=list)
35
+
36
+ @property
37
+ def all_ok(self) -> bool:
38
+ return all(t.ok for t in self.tables)
39
+
40
+ def to_dict(self) -> dict:
41
+ return {
42
+ "all_ok": self.all_ok,
43
+ "tables": [
44
+ {"table": t.table, "source_count": t.source_count, "target_count": t.target_count,
45
+ "counts_match": t.counts_match, "checksums_match": t.checksums_match}
46
+ for t in self.tables
47
+ ],
48
+ }
49
+
50
+
51
+ def _row_checksum(connection, table: str, columns: list[str]) -> str:
52
+ """Concatena todas as linhas (ordenadas por todas as colunas, pra não depender de ordem física
53
+ de armazenamento) e faz hash — barato o bastante pra tabelas de tamanho moderado; ver
54
+ `skip_checksum_above` em `validate_table` pra tabelas grandes onde isso seria caro demais."""
55
+ col_list = ",".join(columns)
56
+ rows = connection.execute(f"SELECT {col_list} FROM {table} ORDER BY {col_list}").fetchall()
57
+ h = hashlib.sha256()
58
+ for row in rows:
59
+ values = [row[c] if hasattr(row, "keys") else row[i] for i, c in enumerate(columns)]
60
+ h.update("|".join(str(v) for v in values).encode("utf-8", errors="replace"))
61
+ h.update(b"\x00")
62
+ return h.hexdigest()
63
+
64
+
65
+ def validate_table(source_connection, target_connection, table: str, columns: list[str] | None = None,
66
+ skip_checksum_above: int = 100_000) -> TableValidation:
67
+ source_count = source_connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
68
+ target_count = target_connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
69
+
70
+ source_checksum = target_checksum = None
71
+ if columns and source_count <= skip_checksum_above:
72
+ source_checksum = _row_checksum(source_connection, table, columns)
73
+ target_checksum = _row_checksum(target_connection, table, columns)
74
+
75
+ return TableValidation(table=table, source_count=source_count, target_count=target_count,
76
+ source_checksum=source_checksum, target_checksum=target_checksum)
77
+
78
+
79
+ def validate(source_connection, target_connection, tables: dict[str, list[str] | None],
80
+ skip_checksum_above: int = 100_000) -> ValidationReport:
81
+ """`tables`: mapa nome_tabela -> lista de colunas (ou None pra pular checksum, só contar)."""
82
+ report = ValidationReport()
83
+ for table, columns in tables.items():
84
+ report.tables.append(validate_table(source_connection, target_connection, table, columns, skip_checksum_above))
85
+ return report
@@ -0,0 +1,3 @@
1
+ from .report import render_report
2
+
3
+ __all__ = ["render_report"]
@@ -0,0 +1,58 @@
1
+ """Geração de relatório unificado — usado pelos comandos `scan`, `report`, `migrate-data`, `validate`
2
+ da CLI. Formato comum (texto/JSON) pra qualquer um dos resultados do pacote (ScanReport,
3
+ SchemaMigrationResult, DataMigrationResult, ValidationReport), sem cada comando reinventar a própria
4
+ formatação de saída.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import asdict, is_dataclass
10
+ from datetime import datetime, timezone
11
+
12
+
13
+ def _to_jsonable(obj):
14
+ if is_dataclass(obj) and not isinstance(obj, type):
15
+ return {k: _to_jsonable(v) for k, v in asdict(obj).items()}
16
+ if isinstance(obj, dict):
17
+ return {k: _to_jsonable(v) for k, v in obj.items()}
18
+ if isinstance(obj, (list, tuple)):
19
+ return [_to_jsonable(v) for v in obj]
20
+ if hasattr(obj, "__fspath__"): # Path
21
+ return str(obj)
22
+ return obj
23
+
24
+
25
+ def render_report(title: str, payload, fmt: str = "text") -> str:
26
+ """`payload` pode ser um objeto com `.to_dict()` (ScanReport/ValidationReport) ou qualquer
27
+ dataclass (SchemaMigrationResult/DataMigrationResult) — normaliza os dois casos."""
28
+ data = payload.to_dict() if hasattr(payload, "to_dict") else _to_jsonable(payload)
29
+
30
+ if fmt == "json":
31
+ return json.dumps({
32
+ "title": title,
33
+ "generated_at": datetime.now(timezone.utc).isoformat(),
34
+ "data": data,
35
+ }, indent=2, ensure_ascii=False)
36
+
37
+ lines = [f"=== {title} ===", f"Gerado em: {datetime.now(timezone.utc).isoformat()}", ""]
38
+ lines.append(_dict_to_text(data))
39
+ return "\n".join(lines)
40
+
41
+
42
+ def _dict_to_text(data, indent: int = 0) -> str:
43
+ prefix = " " * indent
44
+ lines = []
45
+ if isinstance(data, dict):
46
+ for k, v in data.items():
47
+ if isinstance(v, (dict, list)):
48
+ lines.append(f"{prefix}{k}:")
49
+ lines.append(_dict_to_text(v, indent + 1))
50
+ else:
51
+ lines.append(f"{prefix}{k}: {v}")
52
+ elif isinstance(data, list):
53
+ for item in data:
54
+ lines.append(_dict_to_text(item, indent))
55
+ lines.append("")
56
+ else:
57
+ lines.append(f"{prefix}{data}")
58
+ return "\n".join(lines)
@@ -0,0 +1,3 @@
1
+ from .scanner import Finding, ScanReport, scan_file, scan_project
2
+
3
+ __all__ = ["Finding", "ScanReport", "scan_file", "scan_project"]
@@ -0,0 +1,176 @@
1
+ """Scanner: percorre um projeto Python procurando SQL potencialmente incompatível entre o dialeto
2
+ origem e destino escolhidos. Generaliza o scanner do PostgresModel (que só conhecia SQLite->Postgres)
3
+ pra qualquer par suportado — a diferença de risco entre `?`->`%s` (Postgres) e `?`->`?` (MySQL, sem
4
+ mudança) só faz sentido sabendo os dois lados, por isso o scanner recebe `source`/`target`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Iterable
12
+
13
+ RISK_LOW = "LOW"
14
+ RISK_MEDIUM = "MEDIUM"
15
+ RISK_HIGH = "HIGH"
16
+
17
+ # Placeholder nativo de cada dialeto — usado pra saber se `?`/`%s`/etc precisa mudar entre origem/destino.
18
+ _PLACEHOLDER_STYLE = {
19
+ "sqlite": "?",
20
+ "postgres": "%s",
21
+ "mysql": "%s",
22
+ "sqlserver": "?",
23
+ }
24
+
25
+ _AUTOINCREMENT_KEYWORDS = {
26
+ "sqlite": "AUTOINCREMENT",
27
+ "postgres": "GENERATED",
28
+ "mysql": "AUTO_INCREMENT",
29
+ "sqlserver": "IDENTITY",
30
+ }
31
+
32
+
33
+ @dataclass
34
+ class Finding:
35
+ file: Path
36
+ line: int
37
+ category: str
38
+ risk: str
39
+ snippet: str
40
+ suggestion: str
41
+
42
+
43
+ @dataclass
44
+ class ScanReport:
45
+ source: str
46
+ target: str
47
+ findings: list[Finding] = field(default_factory=list)
48
+
49
+ def by_risk(self, risk: str) -> list[Finding]:
50
+ return [f for f in self.findings if f.risk == risk]
51
+
52
+ def by_category(self) -> dict[str, list[Finding]]:
53
+ out: dict[str, list[Finding]] = {}
54
+ for f in self.findings:
55
+ out.setdefault(f.category, []).append(f)
56
+ return out
57
+
58
+ def to_text(self) -> str:
59
+ lines = [f"Origem: {self.source} -> Destino: {self.target}"]
60
+ for category, items in sorted(self.by_category().items()):
61
+ lines.append(f"\n=== {category} ({len(items)} ocorrência(s)) ===")
62
+ for it in items:
63
+ lines.append(f" [{it.risk}] {it.file}:{it.line} {it.snippet!r}")
64
+ lines.append(f" -> {it.suggestion}")
65
+ lines.append(f"\nTotal: {len(self.findings)} achado(s), "
66
+ f"{len(self.by_risk(RISK_HIGH))} de alto risco (REVIEW_REQUIRED).")
67
+ return "\n".join(lines)
68
+
69
+ def to_dict(self) -> dict:
70
+ return {
71
+ "source": self.source, "target": self.target, "total": len(self.findings),
72
+ "by_risk": {r: len(self.by_risk(r)) for r in (RISK_LOW, RISK_MEDIUM, RISK_HIGH)},
73
+ "findings": [
74
+ {"file": str(f.file), "line": f.line, "category": f.category,
75
+ "risk": f.risk, "snippet": f.snippet, "suggestion": f.suggestion}
76
+ for f in self.findings
77
+ ],
78
+ }
79
+
80
+
81
+ def _extract_sql_text(node: ast.AST) -> str | None:
82
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
83
+ return node.value
84
+ if isinstance(node, ast.JoinedStr):
85
+ parts = []
86
+ for v in node.values:
87
+ if isinstance(v, ast.Constant):
88
+ parts.append(str(v.value))
89
+ elif isinstance(v, ast.FormattedValue):
90
+ parts.append("{X}")
91
+ return "".join(parts)
92
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
93
+ left = _extract_sql_text(node.left)
94
+ right = _extract_sql_text(node.right)
95
+ if left is not None and right is not None:
96
+ return left + right
97
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "join":
98
+ return "{JOIN}"
99
+ return None
100
+
101
+
102
+ def _classify(sql: str, file: Path, line: int, source: str, target: str) -> list[Finding]:
103
+ import re
104
+ findings: list[Finding] = []
105
+
106
+ src_ph = _PLACEHOLDER_STYLE.get(source, "?")
107
+ tgt_ph = _PLACEHOLDER_STYLE.get(target, "?")
108
+ if src_ph in sql and src_ph != tgt_ph:
109
+ findings.append(Finding(file, line, "PLACEHOLDER", RISK_LOW, src_ph,
110
+ f"'{src_ph}' -> '{tgt_ph}' — traduzido pelo compat runtime."))
111
+
112
+ src_autoinc = _AUTOINCREMENT_KEYWORDS.get(source)
113
+ if src_autoinc and re.search(rf"\b{src_autoinc}\b", sql, re.IGNORECASE) and "CREATE TABLE" in sql.upper():
114
+ findings.append(Finding(file, line, "AUTOINCREMENT", RISK_LOW, src_autoinc,
115
+ f"Sintaxe de autoincrement de {source} — traduzida automaticamente em DDL."))
116
+
117
+ if re.search(r"\bINSERT\s+OR\s+IGNORE\b", sql, re.IGNORECASE):
118
+ risk = RISK_LOW if target in ("postgres", "sqlite") else RISK_HIGH
119
+ findings.append(Finding(file, line, "INSERT_OR_IGNORE", risk, "INSERT OR IGNORE",
120
+ "ON CONFLICT DO NOTHING (Postgres/SQLite) — verificar equivalente pro destino."))
121
+
122
+ if re.search(r"\bINSERT\s+OR\s+REPLACE\b", sql, re.IGNORECASE):
123
+ findings.append(Finding(file, line, "INSERT_OR_REPLACE", RISK_HIGH, "INSERT OR REPLACE",
124
+ "Precisa saber a coluna de conflito — nunca traduzido automaticamente."))
125
+
126
+ if re.search(r"\bsqlite_master\b", sql, re.IGNORECASE) and target != "sqlite":
127
+ findings.append(Finding(file, line, "SQLITE_MASTER", RISK_HIGH, "sqlite_master",
128
+ "Sem equivalente direto fora do SQLite — trocar por catálogo do destino "
129
+ "(information_schema no Postgres/MySQL)."))
130
+
131
+ if re.search(r"\bPRAGMA\s+\w+", sql, re.IGNORECASE) and target != "sqlite":
132
+ findings.append(Finding(file, line, "PRAGMA", RISK_HIGH, "PRAGMA",
133
+ "Comando específico do SQLite — sem equivalente direto no destino."))
134
+
135
+ if re.search(r"\bLIKE\b", sql, re.IGNORECASE) and not re.search(r"\bILIKE\b", sql, re.IGNORECASE) and target == "postgres":
136
+ findings.append(Finding(file, line, "LIKE_CASE_SENSITIVITY", RISK_HIGH, "LIKE",
137
+ "Case-sensitivity muda entre dialetos — confirmar se ILIKE é necessário."))
138
+
139
+ return findings
140
+
141
+
142
+ class _ExecuteVisitor(ast.NodeVisitor):
143
+ def __init__(self, file: Path, source: str, target: str):
144
+ self.file = file
145
+ self.source = source
146
+ self.target = target
147
+ self.findings: list[Finding] = []
148
+
149
+ def visit_Call(self, node: ast.Call):
150
+ if isinstance(node.func, ast.Attribute) and node.func.attr in ("execute", "executemany"):
151
+ if node.args:
152
+ sql = _extract_sql_text(node.args[0])
153
+ if sql is not None:
154
+ self.findings.extend(_classify(sql, self.file, node.lineno, self.source, self.target))
155
+ self.generic_visit(node)
156
+
157
+
158
+ def scan_file(path: Path, source: str, target: str) -> list[Finding]:
159
+ try:
160
+ src = path.read_text(encoding="utf-8")
161
+ tree = ast.parse(src, filename=str(path))
162
+ except (SyntaxError, UnicodeDecodeError) as e:
163
+ return [Finding(path, 0, "PARSE_ERROR", RISK_HIGH, str(e), "Arquivo não pôde ser lido/parseado.")]
164
+ visitor = _ExecuteVisitor(path, source, target)
165
+ visitor.visit(tree)
166
+ return visitor.findings
167
+
168
+
169
+ def scan_project(root: Path, source: str, target: str,
170
+ exclude: Iterable[str] = ("venv", ".venv", "__pycache__", "node_modules")) -> ScanReport:
171
+ report = ScanReport(source=source, target=target)
172
+ for path in sorted(Path(root).rglob("*.py")):
173
+ if any(part in exclude for part in path.parts):
174
+ continue
175
+ report.findings.extend(scan_file(path, source, target))
176
+ return report