1

I'm writing a Discord bot that will accept user input as a string and then evaluate the expression inside it, nothing fancy just simple arithmetic operations. I have two concerns - safety and decimals. First I used simpleeval package, it worked fine but it had trouble with decimals, e.g 0.1 + 0.1 + 0.1 - 0.3 would return 5.551115123125783e-17. After googling a lot I found an answer that work's but it uses the built in eval() function and apparently using it is a big no.

Is there a better/safer way of handling this? This implements https://docs.python.org/3/library/tokenize.html#examples decistmt() method which substitutes Decimals for floats in a string of statements. But in the end I use eval() and with all those checks I'm still unsure if it's safe and I'd rather avoid it.

This is what decistmt() does:

from tokenize import tokenize, untokenize, NUMBER, STRING, NAME, OP
from io import BytesIO

def decistmt(s):
    """Substitute Decimals for floats in a string of statements.

    >>> from decimal import Decimal
    >>> s = 'print(+21.3e-5*-.1234/81.7)'
    >>> decistmt(s)
    "print (+Decimal ('21.3e-5')*-Decimal ('.1234')/Decimal ('81.7'))"

    The format of the exponent is inherited from the platform C library.
    Known cases are "e-007" (Windows) and "e-07" (not Windows).  Since
    we're only showing 12 digits, and the 13th isn't close to 5, the
    rest of the output should be platform-independent.

    >>> exec(s)  #doctest: +ELLIPSIS
    -3.21716034272e-0...7

    Output from calculations with Decimal should be identical across all
    platforms.

    >>> exec(decistmt(s))
    -3.217160342717258261933904529E-7
    """
    result = []
    g = tokenize(BytesIO(s.encode('utf-8')).readline)  # tokenize the string
    for toknum, tokval, _, _, _ in g:
        if toknum == NUMBER and '.' in tokval:  # replace NUMBER tokens
            result.extend([
                (NAME, 'Decimal'),
                (OP, '('),
                (STRING, repr(tokval)),
                (OP, ')')
            ])
        else:
            result.append((toknum, tokval))
    return untokenize(result).decode('utf-8')
 # example user input: "(20+5)^4 - 550 + 8"
@bot.command()
async def calc(context, *, user_input):
   
    #this is so user can use both ^ and ** for power and use "," and "." for decimals
    equation = user_input.replace('^', "**").replace(",", ".")
    valid_operators: list = ["+", "-", "/", "*", "%", "^", "**"]
    # checks if a string contains any element from a list, it will also return false if the iterable is empty, so this covers empty check too
    operator_check: bool = any(
        operator in equation for operator in valid_operators)
    # checks if arithmetic operator is last or first element in equation, to prevent doing something like ".calc 2+" or ".calc +2"

    def is_last_or_first(equation: str):
        for operator in valid_operators:
            if operator == equation[-1]:
                return True
            elif operator == equation[0]:
                if operator == "-":
                    return False
                else:
                    return True
    #isupper and islower checks whether there are letters in user input
    if not operator_check or is_last_or_first(equation) or equation.isupper() or equation.islower():
        return await context.send("Invalid input")

    result = eval(decistmt(equation))
    result = float(result)

    # returning user_input here so user sees "^" instead of "**"

    async def result_generator(result: int or float):
        await context.send(f'**Input:** ```fix\n{user_input}```**Result:** ```fix\n{result}```')
    # this is so if the result is .0 it returns an int
    if result.is_integer():
        await result_generator(int(result))
    else:
        await result_generator(result)

This is what happens after user input

user_input = "0.1 + 0.1 + 0.1 - 0.3"
float_to_decimal = decistmt(user_input) 
print(float_to_decimal)
print(type(float_to_decimal))
# Decimal ('0.1')+Decimal ('0.1')+Decimal ('0.1')-Decimal ('0.3')
# <class 'str'>

Now I need to evaluate this input so I'm using eval(). My question is - is this safe (I assume not) and is there some other way to evaluate float_to_decimal?

EDIT. As requested, more in depth explanation:

The whole application is a chat bot. This "calc" function is one of the commands users can use. It is invoked by inputing ".calc " in the chat, ".calc" is a prefix, anything after that is arguments and it will be concatenated to a string and ultimately a string is what I'll get as an argument. I perform a bunch of checks to limit the input (remove letters etc.). After checks I am left with a string consisting of numbers, arithmetic operators and brackets. I want to evaluate the mathematical expression from that string. I pass that string to decistmt function which transforms each float in that string to Decimal objects, the result IS A STRING looking like this: "Decimal ('2.5') + Decimal ('-5.2')". Now I need to evaluate the expression inside that string. I used simpleeval module for that but it is incompatible with Decimal module so I'm evaluating using built in eval() method. My question is, is there a safer way of evaluating mathematical expression in a string like that?

krzys.dev
  • 45
  • 1
  • 5
  • Is the inbuilt [decimal](https://docs.python.org/3/library/decimal.html) library insufficient for your use case? – Shiva Apr 10 '21 at 17:13
  • It already uses decimal. I edited the question to be more clear. The parsing is done, the problem is evaluating the parsed string. – krzys.dev Apr 10 '21 at 18:06
  • Unfortunately, your question is still not clear. Before mentioning `simpleeval` or any other package or code, tell us why exactly you are using `simpleeval`. What code did you wrote earlier that is not giving you the expected result. Show us some example inputs and outputs and how the `decimal` module is not able to do that. – Shiva Apr 11 '21 at 07:41
  • I edited my question once again. If it is not clear now then unfortunately I might lack the ability to explain what I want to do. In the end everything works, I'm just asking if I can avoid using eval(), which according to what I've read, should never be used and "there is always a way to avoid using it". – krzys.dev Apr 11 '21 at 11:35
  • This should help - https://stackoverflow.com/a/9558001/3007402 – Shiva Apr 11 '21 at 18:28

2 Answers2

1

A recent spinoff package from pyparsing is plusminus, a wrapper around pyparsing specifically for this case of embedded arithmetic evaluation. You can try it yourself at http://ptmcg.pythonanywhere.com/plusminus. Since plusminus uses its own parser, it is not subject to the common eval attacks, as Ned Batchelder describes in this still-timely blog post: https://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html. Plusminus also includes handling for expressions which, while valid and "safe", are so time-consuming that they could be used for a denial-of-service attack - such as "9**9**9".

I'm planning a refactoring pass on plusminus that will change the API a bit, but you could probably use it as-is for your Discord plugin. (You'll find that plusminus also supports a number of additional operators and functions beyond the standard ones defined for Python, such as "|4-7|" for absolute value, or "√42" for sqrt(42) - click the Examples button on that page for more expressions that are supported. You can also save values in a variable, and then use that variable in later expressions. (This may not work for your plugin, since the variable state might be shared by multiple Discordians.)

Plusminus is also designed to support subclassing to define new operators, such as a dice roller that will evaluate "3d6+d20" as "3 rolls of a 6-sided die, plus 1 roll of a 20-sided die", including random die rolls each time it is evaluated.

PaulMcG
  • 62,419
  • 16
  • 94
  • 130
0

Try using Decimal on numbers as @Shiva suggested.

Also here's the answer from user @unutbu which consists of using PyParsing library with custom wrapper for evaluating math expressions. In case of Python or system expressions (e.g. import <package> or dir()) will throw an ParseException error.

  • I'm already using decimal, I edited my question to be more clear what my problem is (it's evaluating the string I get as a result). – krzys.dev Apr 10 '21 at 18:04