Browse Source

[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>
Grant Lanham 6 months ago
parent
commit
3e87354f0e

+ 23 - 8
searx/plugins/calculator.py

@@ -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)
-
-    if result != query:
-        search.result_container.answers['calculate'] = {'answer': f"{query} = {result}"}
+    result = babel.numbers.format_decimal(result, locale=search.search_query.locale)
+    search.result_container.answers['calculate'] = {'answer': f"{search.search_query.query} = {result}"}
     return True
     return True

+ 23 - 8
tests/unit/test_plugin_calculator.py

@@ -42,20 +42,35 @@ class PluginCalculator(SearxTestCase):  # pylint: disable=missing-class-docstrin
 
 
     @parameterized.expand(
     @parameterized.expand(
         [
         [
-            "1+1",
-            "1-1",
-            "1*1",
-            "1/1",
-            "1**1",
-            "1^1",
+            ("1+1", "2", "en-US"),
+            ("1-1", "0", "en-US"),
+            ("1*1", "1", "en-US"),
+            ("1/1", "1", "en-US"),
+            ("1**1", "1", "en-US"),
+            ("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(
         [
         [

+ 3 - 0
tests/unit/test_plugins.py

@@ -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={}))
 
 
 
 

+ 2 - 0
tests/unit/test_webapp.py

@@ -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)