|
|
|
|
|
|
|
|
"""
|
|
|
env_audit.py — Auditoria de ambiente (TESTE/Produção) para projetos Streamlit
|
|
|
Autor: Rodrigo / ARM | 2026
|
|
|
|
|
|
Verifica:
|
|
|
• Uso de st.set_page_config fora de if __name__ == "__main__"
|
|
|
• Presença de funções main()/render() em módulos
|
|
|
• Diferenças entre arquivos .py e modules_map.py (sugestão de "file")
|
|
|
• Heurística de banco (banco.py apontando para produção)
|
|
|
• Gera env_audit_report.json + imprime resumo Markdown
|
|
|
|
|
|
Uso:
|
|
|
python env_audit.py --env "C:\\...\\ambiente_teste\\LoadApp"
|
|
|
(ou) comparar pastas:
|
|
|
python env_audit.py --prod "C:\\...\\producao\\LoadApp" --test "C:\\...\\ambiente_teste\\LoadApp"
|
|
|
"""
|
|
|
|
|
|
import os, sys, json, ast, re
|
|
|
from typing import Dict, List, Tuple, Optional
|
|
|
|
|
|
def list_py(base: str) -> List[str]:
|
|
|
files = []
|
|
|
for root, _, names in os.walk(base):
|
|
|
for n in names:
|
|
|
if n.endswith(".py"):
|
|
|
files.append(os.path.normpath(os.path.join(root, n)))
|
|
|
return files
|
|
|
|
|
|
def relpath_set(base: str) -> set:
|
|
|
return set([os.path.relpath(p, base) for p in list_py(base)])
|
|
|
|
|
|
def parse_ast(path: str) -> Optional[ast.AST]:
|
|
|
try:
|
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
|
src = f.read()
|
|
|
return ast.parse(src, filename=path)
|
|
|
except Exception:
|
|
|
return None
|
|
|
|
|
|
def has_set_page_config_outside_main(tree: ast.AST) -> bool:
|
|
|
"""
|
|
|
Retorna True se encontrar chamada a set_page_config fora do guard if __name__ == "__main__".
|
|
|
Heurística: procura ast.Call para nome/atributo 'set_page_config' e confere se está dentro de um If guard.
|
|
|
"""
|
|
|
if tree is None:
|
|
|
return False
|
|
|
|
|
|
calls = []
|
|
|
parents = {}
|
|
|
|
|
|
class ParentAnnotator(ast.NodeVisitor):
|
|
|
def generic_visit(self, node):
|
|
|
for child in ast.iter_child_nodes(node):
|
|
|
parents[child] = node
|
|
|
super().generic_visit(node)
|
|
|
|
|
|
class CallCollector(ast.NodeVisitor):
|
|
|
def visit_Call(self, node):
|
|
|
|
|
|
name = None
|
|
|
if isinstance(node.func, ast.Name):
|
|
|
name = node.func.id
|
|
|
elif isinstance(node.func, ast.Attribute):
|
|
|
name = node.func.attr
|
|
|
if name == "set_page_config":
|
|
|
calls.append(node)
|
|
|
self.generic_visit(node)
|
|
|
|
|
|
ParentAnnotator().visit(tree)
|
|
|
CallCollector().visit(tree)
|
|
|
|
|
|
def inside_main_guard(node: ast.AST) -> bool:
|
|
|
|
|
|
cur = node
|
|
|
while cur in parents:
|
|
|
cur = parents[cur]
|
|
|
if isinstance(cur, ast.If):
|
|
|
|
|
|
t = cur.test
|
|
|
|
|
|
if isinstance(t, ast.Compare):
|
|
|
left = t.left
|
|
|
comparators = t.comparators
|
|
|
if isinstance(left, ast.Name) and left.id == "__name__" and comparators:
|
|
|
comp = comparators[0]
|
|
|
if isinstance(comp, ast.Constant) and comp.value == "__main__":
|
|
|
return True
|
|
|
return False
|
|
|
|
|
|
|
|
|
for c in calls:
|
|
|
if not inside_main_guard(c):
|
|
|
return True
|
|
|
return False
|
|
|
|
|
|
def has_function(tree: ast.AST, fn_name: str) -> bool:
|
|
|
if tree is None:
|
|
|
return False
|
|
|
for node in ast.walk(tree):
|
|
|
if isinstance(node, ast.FunctionDef) and node.name == fn_name:
|
|
|
return True
|
|
|
return False
|
|
|
|
|
|
def guess_module_name(file_path: str, base: str) -> str:
|
|
|
"""Retorna o nome do módulo sem extensão e sem subdiretórios ('modulos/x.py' -> 'x')."""
|
|
|
rel = os.path.relpath(file_path, base)
|
|
|
name = os.path.splitext(os.path.basename(rel))[0]
|
|
|
return name
|
|
|
|
|
|
def read_modules_map(path: str) -> Dict[str, Dict]:
|
|
|
"""
|
|
|
Lê modules_map.py e tenta extrair o dict MODULES.
|
|
|
Método simples: regex para entries de primeiro nível.
|
|
|
"""
|
|
|
modmap = {}
|
|
|
mm_path = os.path.join(path, "modules_map.py")
|
|
|
if not os.path.isfile(mm_path):
|
|
|
return modmap
|
|
|
try:
|
|
|
with open(mm_path, "r", encoding="utf-8") as f:
|
|
|
src = f.read()
|
|
|
|
|
|
|
|
|
main_match = re.search(r"MODULES\s*=\s*\{(.+?)\}\s*$", src, flags=re.S)
|
|
|
if not main_match:
|
|
|
return modmap
|
|
|
body = main_match.group(1)
|
|
|
|
|
|
entry_re = re.compile(r'"\'["\']\s*:\s*\{(.*?)\}', re.S)
|
|
|
for em in entry_re.finditer(body):
|
|
|
key = em.group(1)
|
|
|
obj = em.group(2)
|
|
|
|
|
|
file_m = re.search(r'["\']file["\']\s*:\s*"\'["\']', obj)
|
|
|
label_m = re.search(r'["\']label["\']\s*:\s*"\'["\']', obj)
|
|
|
perfis_m = re.findall(r'["\']perfis["\']\s*:\s*\[([^\]]+)\]', obj)
|
|
|
grupo_m = re.search(r'["\']grupo["\']\s*:\s*"\'["\']', obj)
|
|
|
modmap[key] = {
|
|
|
"file": file_m.group(1) if file_m else None,
|
|
|
"label": label_m.group(1) if label_m else key,
|
|
|
"perfis": perfis_m[0] if perfis_m else None,
|
|
|
"grupo": grupo_m.group(1) if grupo_m else None,
|
|
|
}
|
|
|
except Exception:
|
|
|
pass
|
|
|
return modmap
|
|
|
|
|
|
def audit_env(env_path: str, prod_path: Optional[str] = None) -> Dict:
|
|
|
report = {
|
|
|
"env_path": env_path,
|
|
|
"prod_path": prod_path,
|
|
|
"files_total": 0,
|
|
|
"issues": {
|
|
|
"set_page_config_outside_main": [],
|
|
|
"missing_entry_points": [],
|
|
|
"modules_map_mismatches": [],
|
|
|
"db_prod_risk": [],
|
|
|
"missing_in_test": [],
|
|
|
"extra_in_test": [],
|
|
|
},
|
|
|
"summary": {}
|
|
|
}
|
|
|
|
|
|
env_files = list_py(env_path)
|
|
|
report["files_total"] = len(env_files)
|
|
|
|
|
|
|
|
|
modmap = read_modules_map(env_path)
|
|
|
|
|
|
|
|
|
for f in env_files:
|
|
|
tree = parse_ast(f)
|
|
|
if has_set_page_config_outside_main(tree):
|
|
|
report["issues"]["set_page_config_outside_main"].append(os.path.relpath(f, env_path))
|
|
|
|
|
|
has_main = has_function(tree, "main")
|
|
|
has_render = has_function(tree, "render")
|
|
|
if not (has_main or has_render):
|
|
|
report["issues"]["missing_entry_points"].append((os.path.relpath(f, env_path), has_main, has_render))
|
|
|
|
|
|
|
|
|
banco_path = os.path.join(env_path, "banco.py")
|
|
|
if os.path.isfile(banco_path):
|
|
|
try:
|
|
|
with open(banco_path, "r", encoding="utf-8") as bf:
|
|
|
bsrc = bf.read().lower()
|
|
|
|
|
|
hints = []
|
|
|
if "prod" in bsrc or "production" in bsrc:
|
|
|
hints.append("contém 'prod'/'production' no banco.py (pode estar apontando para produção)")
|
|
|
if "localhost" in bsrc or "127.0.0.1" in bsrc:
|
|
|
hints.append("contém localhost (ok se seu DB de teste for local)")
|
|
|
if "ambiente_teste" in bsrc or "test" in bsrc:
|
|
|
hints.append("contém 'test'/'ambiente_teste' (bom indício de DB de teste)")
|
|
|
if hints:
|
|
|
report["issues"]["db_prod_risk"].extend(hints)
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
module_name_set = set([guess_module_name(f, env_path) for f in env_files])
|
|
|
for key, info in modmap.items():
|
|
|
file_hint = info.get("file")
|
|
|
target = file_hint or key
|
|
|
if target not in module_name_set:
|
|
|
|
|
|
suggestions = [m for m in module_name_set if m.lower() == key.lower()]
|
|
|
sug = suggestions[0] if suggestions else None
|
|
|
report["issues"]["modules_map_mismatches"].append((key, file_hint, sug))
|
|
|
|
|
|
|
|
|
if prod_path and os.path.isdir(prod_path):
|
|
|
prod_set = relpath_set(prod_path)
|
|
|
test_set = relpath_set(env_path)
|
|
|
missing = sorted(list(prod_set - test_set))
|
|
|
extra = sorted(list(test_set - prod_set))
|
|
|
report["issues"]["missing_in_test"] = missing
|
|
|
report["issues"]["extra_in_test"] = extra
|
|
|
|
|
|
|
|
|
report["summary"] = {
|
|
|
"files_total": report["files_total"],
|
|
|
"set_page_config_outside_main_count": len(report["issues"]["set_page_config_outside_main"]),
|
|
|
"missing_entry_points_count": len(report["issues"]["missing_entry_points"]),
|
|
|
"modules_map_mismatches_count": len(report["issues"]["modules_map_mismatches"]),
|
|
|
"db_risk_flags_count": len(report["issues"]["db_prod_risk"]),
|
|
|
"missing_in_test_count": len(report["issues"]["missing_in_test"]),
|
|
|
"extra_in_test_count": len(report["issues"]["extra_in_test"]),
|
|
|
}
|
|
|
|
|
|
return report
|
|
|
|
|
|
def print_markdown(report: Dict):
|
|
|
env_path = report["env_path"]
|
|
|
prod_path = report.get("prod_path") or "—"
|
|
|
print(f"# 🧪 Auditoria de Ambiente\n")
|
|
|
print(f"- **Pasta auditada (TESTE)**: `{env_path}`")
|
|
|
print(f"- **Pasta de PRODUÇÃO (comparação)**: `{prod_path}`\n")
|
|
|
|
|
|
s = report["summary"]
|
|
|
print("## ✅ Resumo")
|
|
|
for k, v in s.items():
|
|
|
print(f"- {k.replace('_',' ').title()}: {v}")
|
|
|
print()
|
|
|
|
|
|
issues = report["issues"]
|
|
|
|
|
|
if issues["set_page_config_outside_main"]:
|
|
|
print("## ⚠️ `st.set_page_config` fora de `if __name__ == '__main__'`")
|
|
|
for f in issues["set_page_config_outside_main"]:
|
|
|
print(f"- {f}")
|
|
|
print("**Ajuste**: mover `st.set_page_config(...)` para dentro de:\n")
|
|
|
print("```python\nif __name__ == \"__main__\":\n st.set_page_config(...)\n main()\n```\n")
|
|
|
|
|
|
if issues["missing_entry_points"]:
|
|
|
print("## ⚠️ Módulos sem `main()` e sem `render()`")
|
|
|
for f, has_main, has_render in issues["missing_entry_points"]:
|
|
|
print(f"- {f} — main={has_main}, render={has_render}")
|
|
|
print("**Ajuste**: garantir pelo menos uma função de entrada (`main` ou `render`).\n")
|
|
|
|
|
|
if issues["modules_map_mismatches"]:
|
|
|
print("## ⚠️ Diferenças entre `modules_map.py` e arquivos")
|
|
|
for key, file_hint, sug in issues["modules_map_mismatches"]:
|
|
|
print(f"- key=`{key}` | file=`{file_hint}` | sugestão de arquivo=`{sug or 'verificar manualmente'}`")
|
|
|
print("**Ajuste**: em `modules_map.py`, defina `\"file\": \"nome_do_arquivo\"` quando o nome do arquivo não for igual à key.\n")
|
|
|
|
|
|
if issues["db_prod_risk"]:
|
|
|
print("## ⚠️ Banco (heurística)")
|
|
|
for m in issues["db_prod_risk"]:
|
|
|
print(f"- {m}")
|
|
|
print("**Ajuste**: confirmar que `banco.py` do TESTE aponta para DB de teste (não produção).\n")
|
|
|
|
|
|
if issues["missing_in_test"]:
|
|
|
print("## 🟠 Arquivos que existem na PRODUÇÃO e faltam no TESTE")
|
|
|
for f in issues["missing_in_test"]:
|
|
|
print(f"- {f}")
|
|
|
print()
|
|
|
|
|
|
if issues["extra_in_test"]:
|
|
|
print("## 🔵 Arquivos que existem no TESTE e não existem na PRODUÇÃO")
|
|
|
for f in issues["extra_in_test"]:
|
|
|
print(f"- {f}")
|
|
|
print()
|
|
|
|
|
|
def main():
|
|
|
import argparse
|
|
|
p = argparse.ArgumentParser()
|
|
|
p.add_argument("--env", help="Pasta do ambiente a auditar (ex.: ...\\ambiente_teste\\LoadApp)")
|
|
|
p.add_argument("--prod", help="(Opcional) Pasta de PRODUÇÃO para comparação.")
|
|
|
p.add_argument("--test", help="(Opcional) Pasta de TESTE para comparação (se usar --prod).")
|
|
|
args = p.parse_args()
|
|
|
|
|
|
if args.env:
|
|
|
env_path = os.path.normpath(args.env)
|
|
|
report = audit_env(env_path)
|
|
|
elif args.prod and args.test:
|
|
|
prod = os.path.normpath(args.prod)
|
|
|
test = os.path.normpath(args.test)
|
|
|
report = audit_env(test, prod_path=prod)
|
|
|
else:
|
|
|
print("Uso: python env_audit.py --env \"C:\\...\\ambiente_teste\\LoadApp\"\n"
|
|
|
" ou: python env_audit.py --prod \"C:\\...\\producao\\LoadApp\" --test \"C:\\...\\ambiente_teste\\LoadApp\"")
|
|
|
sys.exit(2)
|
|
|
|
|
|
|
|
|
out_json = os.path.join(os.getcwd(), "env_audit_report.json")
|
|
|
try:
|
|
|
with open(out_json, "w", encoding="utf-8") as f:
|
|
|
json.dump(report, f, ensure_ascii=False, indent=2)
|
|
|
print(f"\n💾 Relatório salvo em: {out_json}\n")
|
|
|
except Exception as e:
|
|
|
print(f"Falha ao salvar JSON: {e}")
|
|
|
|
|
|
|
|
|
print_markdown(report)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
main()
|
|
|
|