"""Interpretador restrito de fórmula de indicador personalizado (Dashboard Contábil, ver `IndicadorContabilDefinicao.formula` em models.py) — função pura, mesmo espírito de `regras.py`/`indicadores.py`: não toca no ORM, recebe os valores já resolvidos. Nunca usa `eval()`/`compile()` puro (a fórmula é digitada pelo contador, input de usuário) — `ast.parse(..., mode="eval")` gera uma árvore e `_avalia()` só anda por um allowlist restrito de nós (`BinOp` com +-*/, `UnaryOp` com +-, número literal, nome de componente). Qualquer outro nó (chamada de função, atributo, subscrição, comparação, etc.) levanta `FormulaInvalidaError` antes de qualquer coisa ser executada.""" from __future__ import annotations import ast import operator from decimal import Decimal, InvalidOperation _OPERADORES_BINARIOS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, } _OPERADORES_UNARIOS = { ast.UAdd: operator.pos, ast.USub: operator.neg, } class FormulaInvalidaError(Exception): pass def _avalia_no(node: ast.AST, valores: dict[str, Decimal | None]) -> Decimal | None: if isinstance(node, ast.Expression): return _avalia_no(node.body, valores) if isinstance(node, ast.BinOp): operador = _OPERADORES_BINARIOS.get(type(node.op)) if operador is None: raise FormulaInvalidaError("Operador não permitido na fórmula (só +, -, * e / são aceitos).") esquerda = _avalia_no(node.left, valores) direita = _avalia_no(node.right, valores) if esquerda is None or direita is None: return None if isinstance(node.op, ast.Div) and direita == 0: return None return operador(esquerda, direita) if isinstance(node, ast.UnaryOp): operador = _OPERADORES_UNARIOS.get(type(node.op)) if operador is None: raise FormulaInvalidaError("Operador não permitido na fórmula (só +, -, * e / são aceitos).") valor = _avalia_no(node.operand, valores) return None if valor is None else operador(valor) if isinstance(node, ast.Constant): if isinstance(node.value, bool) or not isinstance(node.value, (int, float)): raise FormulaInvalidaError("A fórmula só pode conter números, não pode ter texto.") try: return Decimal(str(node.value)) except InvalidOperation as exc: raise FormulaInvalidaError("Número inválido na fórmula.") from exc if isinstance(node, ast.Name): if node.id not in valores: raise FormulaInvalidaError(f'Componente "{node.id}" não existe nesta fórmula.') return valores[node.id] raise FormulaInvalidaError("A fórmula contém uma operação não permitida (só +, -, *, / e parênteses).") def avalia_formula(expressao: str, valores: dict[str, Decimal | None]) -> Decimal | None: """Avalia `expressao` (ex. "(ativo_circulante - estoques) / passivo_circulante") contra os valores já resolvidos dos componentes (`valores`, chave -> Decimal ou None). Se qualquer nome referenciado tiver valor `None` ("indisponível") em qualquer ponto da árvore, ou a fórmula fizer uma divisão por zero, o resultado inteiro é `None` — nunca uma exceção nem um 0 mascarando indisponibilidade (mesmo espírito de `IndicadoresFinanceiros`, ver CLAUDE.md do pacote). Levanta `FormulaInvalidaError` só quando a fórmula em si é inválida (sintaxe, operador não permitido, componente inexistente) — isso sempre deve ser pego na hora de salvar a definição, não durante o cálculo de uma apuração.""" expressao = (expressao or "").strip() if not expressao: raise FormulaInvalidaError("Informe a fórmula do indicador.") try: arvore = ast.parse(expressao, mode="eval") except SyntaxError as exc: raise FormulaInvalidaError(f"Fórmula inválida: {exc.msg}.") from exc return _avalia_no(arvore, valores) def valida_formula(expressao: str, chaves_disponiveis: set[str]) -> None: """Validação "a seco" na hora de salvar a definição — roda a mesma árvore contra valores fictícios (todo componente = 1) só pra garantir que a fórmula é sintaticamente válida e só referencia nomes conhecidos, sem se importar com o resultado numérico em si (`None` também seria um resultado "válido" durante o cálculo real, ex. divisão por zero, então não dá pra usar o valor de retorno aqui pra decidir nada).""" valores_ficticios = {chave: Decimal(1) for chave in chaves_disponiveis} avalia_formula(expressao, valores_ficticios)