Source code for formparse.formula

"""Formula that can be parsed and evaluated
"""
import ast
import logging
import operator
from typing import Any, Dict, Optional, Tuple

__author__ = 'Nicklas Bocksberger'
__copyright__ = 'Nicklas Bocksberger'
__license__ = 'MIT'

_logger = logging.getLogger(__name__)


[docs]class FormulaException(Exception): """Generic Exception for `Formula`, base class for `FormulaSyntaxError` and `FormulaRuntimeError`. """
[docs]class FormulaSyntaxError(FormulaException): """Exception raised if there is an error in the syntax of the formula input. """
[docs]class FormulaRuntimeError(FormulaException): """Exception raised if there is an error during the runtime of the formula, especially with the argument input. """
[docs]class FormulaZeroDivisionError(FormulaRuntimeError): """Exception raised if there is a division throgh 0 error. """
[docs]class FormulaComplexityError(FormulaRuntimeError): """Exception raised if the expected result is to big to calculate. """
[docs]class Formula: """Simple formula, generated from a string input can it be evaluated with it `.eval()`method. The currently supported operators are `+`, `-`, `*` and `/`. """ EVALUATORS = { ast.Expression: '_eval_expression', ast.Constant: '_eval_constant', ast.Name: '_eval_name', ast.BinOp: '_eval_binop', ast.UnaryOp: '_eval_unaryop', ast.Call: '_eval_call', } BIN_OPERATORS = { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, } UN_OPERATORS = { ast.USub: operator.neg, } FUNCTIONS = { 'max': max, 'abs': abs, 'min': min, } MAX_FORMULA_LENGTH = 255 """Maximum length accepted for the formula, as is (incl. whitespaces, etc.) """ MAX_RESULT_POTENCY = 18 """Maximum maximum power of ten that is allowed for a expected result. """ def __init__(self, formula: str) -> None: """ Args: formula (str): The formula as a string, arguments can passed during evaluation. Raises: FormulaSyntaxError: Raised if the formula is not valid. """ self.formula = formula self.node = self.parse_formula(self.formula) self._validate_formula() def _validate_formula(self): if self.MAX_FORMULA_LENGTH and len(self.formula) > self.MAX_FORMULA_LENGTH: raise FormulaSyntaxError('Formula can be 255 characters maximum.') valid, *problem = self.validate(self.node) if not valid: raise FormulaSyntaxError(problem)
[docs] @classmethod def parse_formula(cls, formula: str) -> ast.AST: """Parse a given formula into an `ast` node. Args: formula (str): Formula to parse. Returns: ast.AST: Parsed node. """ try: return ast.parse(formula, '<string>', mode='eval') except SyntaxError as exception: raise FormulaSyntaxError('Could not parse formula.') from exception
[docs] @classmethod def validate(cls, node: ast.AST or str) -> Tuple[bool, str or None]: """Check whether or not formula provided in `node` is valid. `NOTE`: `Formula.validate()` does not check for length constraint but just for general validity. Args: node (ast.AST | str): Formula to check, either as `str` or parsed `ast` Tree. Returns: Tuple[bool, str | None]: If the formula is valid, if not, provide reason in second value. """ if isinstance(node, str): try: node = cls.parse_formula(node) except FormulaSyntaxError as exception: return False, str(exception) # TODO: change to case match in version 1, change or to union operator if isinstance(node, ast.Expression): if type(node) in cls.EVALUATORS: return cls.validate(node.body) return False, 'Unknown function.' elif isinstance(node, ast.Constant): if isinstance(node.value, int) or isinstance(node.value, float): return True, None return False, f'Unsopported constant type {type(node.value)}' elif isinstance(node, ast.Name): return True, None elif isinstance(node, ast.BinOp): if type(node.op) in cls.BIN_OPERATORS: return cls.validate(node.left) and cls.validate(node.right) return False, f'Unsopported operator {node.op}' elif isinstance(node, ast.UnaryOp): if type(node.op) in cls.UN_OPERATORS: return cls.validate(node.operand) return False, f'Unsopported operator {node.op}' elif isinstance(node, ast.Call): if node.func.id in cls.FUNCTIONS: return all(cls.validate(arg) for arg in node.args), None return False, f'Unsupported function {node.func.id}' return False, f'Unsopported Function {node}'
[docs] def eval(self, args: Optional[dict]=None) -> float: """Evaluate the formula for a set if given arguments Args: args (Optional[dict], optional): A dictionary with the arguments. Defaults to {}. Raises: FormulaRuntimeError: If the arguments are not a dictionary. FormulaRuntimeError: If the evaluation fails for any other reason. Returns: float: The value of the result. """ if args and not isinstance(args, dict): raise FormulaRuntimeError( f'Invalid type `{type(args)}` for args, only `dict` supported.') try: self.validate_result_size(args) except FormulaComplexityError as exception: raise exception try: return self._eval_node(self.formula, self.node, args) except FormulaSyntaxError: raise except ZeroDivisionError: raise FormulaZeroDivisionError from ZeroDivisionError except Exception as exception: raise FormulaRuntimeError(f'Evaluation failed: {exception}') from exception
def _eval_call(self, source: str, node: ast.AST, args: Dict[str, Any]) -> float: try: func = self.FUNCTIONS[node.func.id] except KeyError as exception: raise FormulaSyntaxError(f'Function {node.func.id} not supported') from exception return func(*[self._eval_node(source, arg, args) for arg in node.args]) def _eval_node(self, source: str, node: ast.AST, args: Dict[str, Any]) -> float: try: eval_name = self.EVALUATORS[type(node)] except KeyError as exception: raise FormulaSyntaxError( 'Could not evaluate, might be due to unsupported operator.') from exception evaluator = getattr(self, eval_name) return evaluator(source, node, args) def _eval_expression(self, source: str, node: ast.Expression, args: Dict[str, Any]) -> float: return self._eval_node(source, node.body, args) def _eval_constant(self, _: str, node: ast.Constant, __: Dict[str, Any]) -> float: if isinstance(node.value, int) or isinstance(node.value, float): return float(node.value) else: raise FormulaSyntaxError(f'Unsupported type of constant {node.value}.') def _eval_name(self, _: str, node: ast.Name, args: Dict[str, Any]) -> float: try: return float(args[node.id]) except KeyError as exception: raise FormulaRuntimeError(f'Undefined variable: {node.id}') from exception def _eval_binop(self, source: str, node: ast.BinOp, args: Dict[str, Any]) -> float: left_value = self._eval_node(source, node.left, args) right_value = self._eval_node(source, node.right, args) try: evaluator = self.BIN_OPERATORS[type(node.op)] except KeyError as exception: raise FormulaSyntaxError('Operations of this type are not supported') from exception return evaluator(left_value, right_value) def _eval_unaryop(self, source: str, node: ast.UnaryOp, args: Dict[str, Any]) -> float: operand_value = self._eval_node(source, node.operand, args) try: apply = self.UN_OPERATORS[type(node.op)] except KeyError as exception: raise FormulaSyntaxError('Operations of this type are not supported') from exception return apply(operand_value) def __str__(self) -> str: return f'<formparse.Formula {self.formula[:32]}>'
[docs] def validate_result_size(self, args: Optional[dict]=None) -> None: """Make sure that the maximum estimated result is not bigger than as set by `MAX_RESULT_POTENCY`. Args: args (Optional[dict], optional): Parameters provided for evaluation, if not provided, each variable is assigned potency of 1. Defaults to None. Raises: FormulaComplexityError: If the expected result is bigger than allowed. """ result_potency = self.estimate_result_size(self.node, args) if result_potency > self.MAX_RESULT_POTENCY: raise FormulaComplexityError( f'Expected result with maximum size 10^{result_potency} is too big.')
[docs] @classmethod def estimate_result_size(cls, node, args: Optional[dict]=None) -> int: """Estimate the result size based one the power of tens of its operands. A more indepth explanation will follow. Args: node (ast.AST): Node to estimate result size for. args (Optional[dict], optional): Parameters provided for evaluation, if not provided, each variable is assigned potency of 1. Defaults to None. Raises: FormulaComplexityError: If calculating the result size itself is to expensive. Returns: int: The maximum power to ten of the result. """ def potency(x: float): return len(str(int(x))) - 1 # might not be elegant but it's simple if isinstance(node, ast.Expression): return cls.estimate_result_size(node.body, args) if isinstance(node, ast.Constant): return potency(node.value) if isinstance(node, ast.Name): if args and node.id in args: return potency(args[node.id]) _logger.warning('Calculating complexity of Formula with unknown %s.', node.id) return 1 if isinstance(node, ast.BinOp): if isinstance(node.op, (ast.Add, ast.Sub)): return max(cls.estimate_result_size(node.left, args), cls.estimate_result_size(node.right, args), ) + 2 if isinstance(node.op, ast.Mult): return cls.estimate_result_size(node.left, args) \ + cls.estimate_result_size(node.right, args) \ + 2 if isinstance(node.op, ast.Div): pot_l = cls.estimate_result_size(node.left, args) pot_r = cls.estimate_result_size(node.right, args) return int((( pot_l + 1) / (pot_r + 1 )) + 0.5) if isinstance(node.op, ast.Pow): pot_l = cls.estimate_result_size(node.left, args) # biggest potency possible on left pot_r = cls.estimate_result_size(node.right, args) # "" on right if pot_r > 5: raise FormulaComplexityError( 'Can\'t calculate result size due to the partial result size') if pot_r > 3: a = cls.estimate_result_size(cls.parse_formula(f'10**({pot_r} + 1) - 1')) if a < cls.MAX_RESULT_POTENCY: raise FormulaComplexityError( 'Can\'t calculate result size due to the partial result size') max_val_r = 10 ** (pot_r + 1) - 1 # biggest value possible on right site return (pot_l + 1) * max_val_r if isinstance(node, ast.UnaryOp): return cls.estimate_result_size(node.operand) if isinstance(node, ast.Call): try: return max(cls.estimate_result_size(arg) for arg in node.args) except ValueError: _logger.warning( 'Calculating complexity of Formula with function %s with unknown result size.', node.func.id, ) return 1