[fix] float operations in calculator plugin

This patch adds an additional *isinstance* check within the ast parser to check
for float along with int, fixing the underlying issue.

Co-Authored: Markus Heiser <markus.heiser@darmarit.de>
This commit is contained in:
Grant Lanham 2024-10-05 16:10:56 +02:00 committed by Markus Heiser
parent d448def1a6
commit 3e87354f0e
4 changed files with 51 additions and 16 deletions

View file

@ -3,9 +3,12 @@
""" """
import ast import ast
import re
import operator import operator
from multiprocessing import Process, Queue from multiprocessing import Process, Queue
from typing import Callable
import babel.numbers
from flask_babel import gettext from flask_babel import gettext
from searx.plugins import logger from searx.plugins import logger
@ -19,7 +22,7 @@ plugin_id = 'calculator'
logger = logger.getChild(plugin_id) logger = logger.getChild(plugin_id)
operators = { operators: dict[type, Callable] = {
ast.Add: operator.add, ast.Add: operator.add,
ast.Sub: operator.sub, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Mult: operator.mul,
@ -39,11 +42,15 @@ def _eval_expr(expr):
>>> _eval_expr('1 + 2*3**(4^5) / (6 + -7)') >>> _eval_expr('1 + 2*3**(4^5) / (6 + -7)')
-5.0 -5.0
""" """
return _eval(ast.parse(expr, mode='eval').body) try:
return _eval(ast.parse(expr, mode='eval').body)
except ZeroDivisionError:
# This is undefined
return ""
def _eval(node): def _eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, int): if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value return node.value
if isinstance(node, ast.BinOp): if isinstance(node, ast.BinOp):
@ -93,6 +100,16 @@ def post_search(_request, search):
# replace commonly used math operators with their proper Python operator # replace commonly used math operators with their proper Python operator
query = query.replace("x", "*").replace(":", "/") query = query.replace("x", "*").replace(":", "/")
# parse the number system in a localized way
def _decimal(match: re.Match) -> str:
val = match.string[match.start() : match.end()]
val = babel.numbers.parse_decimal(val, search.search_query.locale, numbering_system="latn")
return str(val)
decimal = search.search_query.locale.number_symbols["latn"]["decimal"]
group = search.search_query.locale.number_symbols["latn"]["group"]
query = re.sub(f"[0-9]+[{decimal}|{group}][0-9]+[{decimal}|{group}]?[0-9]?", _decimal, query)
# only numbers and math operators are accepted # only numbers and math operators are accepted
if any(str.isalpha(c) for c in query): if any(str.isalpha(c) for c in query):
return True return True
@ -102,10 +119,8 @@ def post_search(_request, search):
# Prevent the runtime from being longer than 50 ms # Prevent the runtime from being longer than 50 ms
result = timeout_func(0.05, _eval_expr, query_py_formatted) result = timeout_func(0.05, _eval_expr, query_py_formatted)
if result is None: if result is None or result == "":
return True return True
result = str(result) result = babel.numbers.format_decimal(result, locale=search.search_query.locale)
search.result_container.answers['calculate'] = {'answer': f"{search.search_query.query} = {result}"}
if result != query:
search.result_container.answers['calculate'] = {'answer': f"{query} = {result}"}
return True return True

View file

@ -42,20 +42,35 @@ class PluginCalculator(SearxTestCase): # pylint: disable=missing-class-docstrin
@parameterized.expand( @parameterized.expand(
[ [
"1+1", ("1+1", "2", "en-US"),
"1-1", ("1-1", "0", "en-US"),
"1*1", ("1*1", "1", "en-US"),
"1/1", ("1/1", "1", "en-US"),
"1**1", ("1**1", "1", "en-US"),
"1^1", ("1^1", "1", "en-US"),
("1,000.0+1,000.0", "2,000", "en-US"),
("1.0+1.0", "2", "en-US"),
("1.0-1.0", "0", "en-US"),
("1.0*1.0", "1", "en-US"),
("1.0/1.0", "1", "en-US"),
("1.0**1.0", "1", "en-US"),
("1.0^1.0", "1", "en-US"),
("1.000,0+1.000,0", "2.000", "de-DE"),
("1,0+1,0", "2", "de-DE"),
("1,0-1,0", "0", "de-DE"),
("1,0*1,0", "1", "de-DE"),
("1,0/1,0", "1", "de-DE"),
("1,0**1,0", "1", "de-DE"),
("1,0^1,0", "1", "de-DE"),
] ]
) )
def test_int_operations(self, operation): def test_localized_query(self, operation: str, contains_result: str, lang: str):
request = Mock(remote_addr='127.0.0.1') request = Mock(remote_addr='127.0.0.1')
search = get_search_mock(query=operation, pageno=1) search = get_search_mock(query=operation, lang=lang, pageno=1)
result = self.store.call(self.store.plugins, 'post_search', request, search) result = self.store.call(self.store.plugins, 'post_search', request, search)
self.assertTrue(result) self.assertTrue(result)
self.assertIn('calculate', search.result_container.answers) self.assertIn('calculate', search.result_container.answers)
self.assertIn(contains_result, search.result_container.answers['calculate']['answer'])
@parameterized.expand( @parameterized.expand(
[ [

View file

@ -1,12 +1,15 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
# pylint: disable=missing-module-docstring # pylint: disable=missing-module-docstring
import babel
from mock import Mock from mock import Mock
from searx import plugins from searx import plugins
from tests import SearxTestCase from tests import SearxTestCase
def get_search_mock(query, **kwargs): def get_search_mock(query, **kwargs):
lang = kwargs.get("lang", "en-US")
kwargs["locale"] = babel.Locale.parse(lang, sep="-")
return Mock(search_query=Mock(query=query, **kwargs), result_container=Mock(answers={})) return Mock(search_query=Mock(query=query, **kwargs), result_container=Mock(answers={}))

View file

@ -4,6 +4,7 @@
import logging import logging
import json import json
from urllib.parse import ParseResult from urllib.parse import ParseResult
import babel
from mock import Mock from mock import Mock
from searx.results import Timing from searx.results import Timing
@ -82,6 +83,7 @@ class ViewsTestCase(SearxTestCase): # pylint: disable=missing-class-docstring,
redirect_url=None, redirect_url=None,
engine_data={}, engine_data={},
) )
search_self.search_query.locale = babel.Locale.parse("en-US", sep='-')
self.setattr4test(Search, 'search', search_mock) self.setattr4test(Search, 'search', search_mock)