105 lines
4.6 KiB
Python
105 lines
4.6 KiB
Python
"""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)
|