summaryrefslogtreecommitdiff
path: root/searx/answerers
diff options
context:
space:
mode:
Diffstat (limited to 'searx/answerers')
-rw-r--r--searx/answerers/__init__.py70
-rw-r--r--searx/answerers/_core.py169
-rw-r--r--searx/answerers/random.py80
-rw-r--r--searx/answerers/random/__init__.py2
-rw-r--r--searx/answerers/random/answerer.py79
-rw-r--r--searx/answerers/statistics.py64
-rw-r--r--searx/answerers/statistics/__init__.py2
-rw-r--r--searx/answerers/statistics/answerer.py53
8 files changed, 347 insertions, 172 deletions
diff --git a/searx/answerers/__init__.py b/searx/answerers/__init__.py
index 346bbb085..1ed85ccc2 100644
--- a/searx/answerers/__init__.py
+++ b/searx/answerers/__init__.py
@@ -1,51 +1,49 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
-# pylint: disable=missing-module-docstring
+"""The *answerers* give instant answers related to the search query, they
+usually provide answers of type :py:obj:`Answer <searx.result_types.Answer>`.
-import sys
-from os import listdir
-from os.path import realpath, dirname, join, isdir
-from collections import defaultdict
+Here is an example of a very simple answerer that adds a "Hello" into the answer
+area:
-from searx.utils import load_module
+.. code::
-answerers_dir = dirname(realpath(__file__))
+ from flask_babel import gettext as _
+ from searx.answerers import Answerer
+ from searx.result_types import Answer
+ class MyAnswerer(Answerer):
-def load_answerers():
- answerers = [] # pylint: disable=redefined-outer-name
+ keywords = [ "hello", "hello world" ]
- for filename in listdir(answerers_dir):
- if not isdir(join(answerers_dir, filename)) or filename.startswith('_'):
- continue
- module = load_module('answerer.py', join(answerers_dir, filename))
- if not hasattr(module, 'keywords') or not isinstance(module.keywords, tuple) or not module.keywords:
- sys.exit(2)
- answerers.append(module)
- return answerers
+ def info(self):
+ return AnswererInfo(name=_("Hello"), description=_("lorem .."), keywords=self.keywords)
+ def answer(self, request, search):
+ return [ Answer(answer="Hello") ]
-def get_answerers_by_keywords(answerers): # pylint:disable=redefined-outer-name
- by_keyword = defaultdict(list)
- for answerer in answerers:
- for keyword in answerer.keywords:
- for keyword in answerer.keywords:
- by_keyword[keyword].append(answerer.answer)
- return by_keyword
+----
+.. autoclass:: Answerer
+ :members:
-def ask(query):
- results = []
- query_parts = list(filter(None, query.query.split()))
+.. autoclass:: AnswererInfo
+ :members:
- if not query_parts or query_parts[0] not in answerers_by_keywords:
- return results
+.. autoclass:: AnswerStorage
+ :members:
- for answerer in answerers_by_keywords[query_parts[0]]:
- result = answerer(query)
- if result:
- results.append(result)
- return results
+.. autoclass:: searx.answerers._core.ModuleAnswerer
+ :members:
+ :show-inheritance:
+"""
-answerers = load_answerers()
-answerers_by_keywords = get_answerers_by_keywords(answerers)
+from __future__ import annotations
+
+__all__ = ["AnswererInfo", "Answerer", "AnswerStorage"]
+
+
+from ._core import AnswererInfo, Answerer, AnswerStorage
+
+STORAGE: AnswerStorage = AnswerStorage()
+STORAGE.load_builtins()
diff --git a/searx/answerers/_core.py b/searx/answerers/_core.py
new file mode 100644
index 000000000..9c50d026e
--- /dev/null
+++ b/searx/answerers/_core.py
@@ -0,0 +1,169 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# pylint: disable=too-few-public-methods, missing-module-docstring
+
+from __future__ import annotations
+
+import abc
+import importlib
+import logging
+import pathlib
+import warnings
+
+from dataclasses import dataclass
+
+from searx.utils import load_module
+from searx.result_types.answer import BaseAnswer
+
+
+_default = pathlib.Path(__file__).parent
+log: logging.Logger = logging.getLogger("searx.answerers")
+
+
+@dataclass
+class AnswererInfo:
+ """Object that holds informations about an answerer, these infos are shown
+ to the user in the Preferences menu.
+
+ To be able to translate the information into other languages, the text must
+ be written in English and translated with :py:obj:`flask_babel.gettext`.
+ """
+
+ name: str
+ """Name of the *answerer*."""
+
+ description: str
+ """Short description of the *answerer*."""
+
+ examples: list[str]
+ """List of short examples of the usage / of query terms."""
+
+ keywords: list[str]
+ """See :py:obj:`Answerer.keywords`"""
+
+
+class Answerer(abc.ABC):
+ """Abstract base class of answerers."""
+
+ keywords: list[str]
+ """Keywords to which the answerer has *answers*."""
+
+ @abc.abstractmethod
+ def answer(self, query: str) -> list[BaseAnswer]:
+ """Function that returns a list of answers to the question/query."""
+
+ @abc.abstractmethod
+ def info(self) -> AnswererInfo:
+ """Informations about the *answerer*, see :py:obj:`AnswererInfo`."""
+
+
+class ModuleAnswerer(Answerer):
+ """A wrapper class for legacy *answerers* where the names (keywords, answer,
+ info) are implemented on the module level (not in a class).
+
+ .. note::
+
+ For internal use only!
+ """
+
+ def __init__(self, mod):
+
+ for name in ["keywords", "self_info", "answer"]:
+ if not getattr(mod, name, None):
+ raise SystemExit(2)
+ if not isinstance(mod.keywords, tuple):
+ raise SystemExit(2)
+
+ self.module = mod
+ self.keywords = mod.keywords # type: ignore
+
+ def answer(self, query: str) -> list[BaseAnswer]:
+ return self.module.answer(query)
+
+ def info(self) -> AnswererInfo:
+ kwargs = self.module.self_info()
+ kwargs["keywords"] = self.keywords
+ return AnswererInfo(**kwargs)
+
+
+class AnswerStorage(dict):
+ """A storage for managing the *answerers* of SearXNG. With the
+ :py:obj:`AnswerStorage.ask`” method, a caller can ask questions to all
+ *answerers* and receives a list of the results."""
+
+ answerer_list: set[Answerer]
+ """The list of :py:obj:`Answerer` in this storage."""
+
+ def __init__(self):
+ super().__init__()
+ self.answerer_list = set()
+
+ def load_builtins(self):
+ """Loads ``answerer.py`` modules from the python packages in
+ :origin:`searx/answerers`. The python modules are wrapped by
+ :py:obj:`ModuleAnswerer`."""
+
+ for f in _default.iterdir():
+ if f.name.startswith("_"):
+ continue
+
+ if f.is_file() and f.suffix == ".py":
+ self.register_by_fqn(f"searx.answerers.{f.stem}.SXNGAnswerer")
+ continue
+
+ # for backward compatibility (if a fork has additional answerers)
+
+ if f.is_dir() and (f / "answerer.py").exists():
+ warnings.warn(
+ f"answerer module {f} is deprecated / migrate to searx.answerers.Answerer", DeprecationWarning
+ )
+ mod = load_module("answerer.py", str(f))
+ self.register(ModuleAnswerer(mod))
+
+ def register_by_fqn(self, fqn: str):
+ """Register a :py:obj:`Answerer` via its fully qualified class namen(FQN)."""
+
+ mod_name, _, obj_name = fqn.rpartition('.')
+ mod = importlib.import_module(mod_name)
+ code_obj = getattr(mod, obj_name, None)
+
+ if code_obj is None:
+ msg = f"answerer {fqn} is not implemented"
+ log.critical(msg)
+ raise ValueError(msg)
+
+ self.register(code_obj())
+
+ def register(self, answerer: Answerer):
+ """Register a :py:obj:`Answerer`."""
+
+ self.answerer_list.add(answerer)
+ for _kw in answerer.keywords:
+ self[_kw] = self.get(_kw, [])
+ self[_kw].append(answerer)
+
+ def ask(self, query: str) -> list[BaseAnswer]:
+ """An answerer is identified via keywords, if there is a keyword at the
+ first position in the ``query`` for which there is one or more
+ answerers, then these are called, whereby the entire ``query`` is passed
+ as argument to the answerer function."""
+
+ results = []
+ keyword = None
+ for keyword in query.split():
+ if keyword:
+ break
+
+ if not keyword or keyword not in self:
+ return results
+
+ for answerer in self[keyword]:
+ for answer in answerer.answer(query):
+ # In case of *answers* prefix ``answerer:`` is set, see searx.result_types.Result
+ answer.engine = f"answerer: {keyword}"
+ results.append(answer)
+
+ return results
+
+ @property
+ def info(self) -> list[AnswererInfo]:
+ return [a.info() for a in self.answerer_list]
diff --git a/searx/answerers/random.py b/searx/answerers/random.py
new file mode 100644
index 000000000..495a077ed
--- /dev/null
+++ b/searx/answerers/random.py
@@ -0,0 +1,80 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# pylint: disable=missing-module-docstring
+
+from __future__ import annotations
+
+import hashlib
+import random
+import string
+import uuid
+from flask_babel import gettext
+
+from searx.result_types import Answer
+from searx.result_types.answer import BaseAnswer
+
+from . import Answerer, AnswererInfo
+
+
+def random_characters():
+ random_string_letters = string.ascii_lowercase + string.digits + string.ascii_uppercase
+ return [random.choice(random_string_letters) for _ in range(random.randint(8, 32))]
+
+
+def random_string():
+ return ''.join(random_characters())
+
+
+def random_float():
+ return str(random.random())
+
+
+def random_int():
+ random_int_max = 2**31
+ return str(random.randint(-random_int_max, random_int_max))
+
+
+def random_sha256():
+ m = hashlib.sha256()
+ m.update(''.join(random_characters()).encode())
+ return str(m.hexdigest())
+
+
+def random_uuid():
+ return str(uuid.uuid4())
+
+
+def random_color():
+ color = "%06x" % random.randint(0, 0xFFFFFF)
+ return f"#{color.upper()}"
+
+
+class SXNGAnswerer(Answerer):
+ """Random value generator"""
+
+ keywords = ["random"]
+
+ random_types = {
+ "string": random_string,
+ "int": random_int,
+ "float": random_float,
+ "sha256": random_sha256,
+ "uuid": random_uuid,
+ "color": random_color,
+ }
+
+ def info(self):
+
+ return AnswererInfo(
+ name=gettext(self.__doc__),
+ description=gettext("Generate different random values"),
+ keywords=self.keywords,
+ examples=[f"random {x}" for x in self.random_types],
+ )
+
+ def answer(self, query: str) -> list[BaseAnswer]:
+
+ parts = query.split()
+ if len(parts) != 2 or parts[1] not in self.random_types:
+ return []
+
+ return [Answer(answer=self.random_types[parts[1]]())]
diff --git a/searx/answerers/random/__init__.py b/searx/answerers/random/__init__.py
deleted file mode 100644
index 9ed59c825..000000000
--- a/searx/answerers/random/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-or-later
-# pylint: disable=missing-module-docstring
diff --git a/searx/answerers/random/answerer.py b/searx/answerers/random/answerer.py
deleted file mode 100644
index 66147fa54..000000000
--- a/searx/answerers/random/answerer.py
+++ /dev/null
@@ -1,79 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-or-later
-# pylint: disable=missing-module-docstring
-
-import hashlib
-import random
-import string
-import uuid
-from flask_babel import gettext
-
-# required answerer attribute
-# specifies which search query keywords triggers this answerer
-keywords = ('random',)
-
-random_int_max = 2**31
-random_string_letters = string.ascii_lowercase + string.digits + string.ascii_uppercase
-
-
-def random_characters():
- return [random.choice(random_string_letters) for _ in range(random.randint(8, 32))]
-
-
-def random_string():
- return ''.join(random_characters())
-
-
-def random_float():
- return str(random.random())
-
-
-def random_int():
- return str(random.randint(-random_int_max, random_int_max))
-
-
-def random_sha256():
- m = hashlib.sha256()
- m.update(''.join(random_characters()).encode())
- return str(m.hexdigest())
-
-
-def random_uuid():
- return str(uuid.uuid4())
-
-
-def random_color():
- color = "%06x" % random.randint(0, 0xFFFFFF)
- return f"#{color.upper()}"
-
-
-random_types = {
- 'string': random_string,
- 'int': random_int,
- 'float': random_float,
- 'sha256': random_sha256,
- 'uuid': random_uuid,
- 'color': random_color,
-}
-
-
-# required answerer function
-# can return a list of results (any result type) for a given query
-def answer(query):
- parts = query.query.split()
- if len(parts) != 2:
- return []
-
- if parts[1] not in random_types:
- return []
-
- return [{'answer': random_types[parts[1]]()}]
-
-
-# required answerer function
-# returns information about the answerer
-def self_info():
- return {
- 'name': gettext('Random value generator'),
- 'description': gettext('Generate different random values'),
- 'examples': ['random {}'.format(x) for x in random_types],
- }
diff --git a/searx/answerers/statistics.py b/searx/answerers/statistics.py
new file mode 100644
index 000000000..e6cbdd008
--- /dev/null
+++ b/searx/answerers/statistics.py
@@ -0,0 +1,64 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# pylint: disable=missing-module-docstring
+from __future__ import annotations
+
+from functools import reduce
+from operator import mul
+
+import babel
+import babel.numbers
+from flask_babel import gettext
+
+from searx.extended_types import sxng_request
+from searx.result_types import Answer
+from searx.result_types.answer import BaseAnswer
+
+from . import Answerer, AnswererInfo
+
+kw2func = [
+ ("min", min),
+ ("max", max),
+ ("avg", lambda args: sum(args) / len(args)),
+ ("sum", sum),
+ ("prod", lambda args: reduce(mul, args, 1)),
+]
+
+
+class SXNGAnswerer(Answerer):
+ """Statistics functions"""
+
+ keywords = [kw for kw, _ in kw2func]
+
+ def info(self):
+
+ return AnswererInfo(
+ name=gettext(self.__doc__),
+ description=gettext(f"Compute {'/'.join(self.keywords)} of the arguments"),
+ keywords=self.keywords,
+ examples=["avg 123 548 2.04 24.2"],
+ )
+
+ def answer(self, query: str) -> list[BaseAnswer]:
+
+ results = []
+ parts = query.split()
+ if len(parts) < 2:
+ return results
+
+ ui_locale = babel.Locale.parse(sxng_request.preferences.get_value('locale'), sep='-')
+
+ try:
+ args = [babel.numbers.parse_decimal(num, ui_locale, numbering_system="latn") for num in parts[1:]]
+ except: # pylint: disable=bare-except
+ # seems one of the args is not a float type, can't be converted to float
+ return results
+
+ for k, func in kw2func:
+ if k == parts[0]:
+ res = func(args)
+ res = babel.numbers.format_decimal(res, locale=ui_locale)
+ f_str = ', '.join(babel.numbers.format_decimal(arg, locale=ui_locale) for arg in args)
+ results.append(Answer(answer=f"[{ui_locale}] {k}({f_str}) = {res} "))
+ break
+
+ return results
diff --git a/searx/answerers/statistics/__init__.py b/searx/answerers/statistics/__init__.py
deleted file mode 100644
index 9ed59c825..000000000
--- a/searx/answerers/statistics/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-or-later
-# pylint: disable=missing-module-docstring
diff --git a/searx/answerers/statistics/answerer.py b/searx/answerers/statistics/answerer.py
deleted file mode 100644
index b0a5ddba5..000000000
--- a/searx/answerers/statistics/answerer.py
+++ /dev/null
@@ -1,53 +0,0 @@
-# SPDX-License-Identifier: AGPL-3.0-or-later
-# pylint: disable=missing-module-docstring
-
-from functools import reduce
-from operator import mul
-
-from flask_babel import gettext
-
-
-keywords = ('min', 'max', 'avg', 'sum', 'prod')
-
-
-# required answerer function
-# can return a list of results (any result type) for a given query
-def answer(query):
- parts = query.query.split()
-
- if len(parts) < 2:
- return []
-
- try:
- args = list(map(float, parts[1:]))
- except: # pylint: disable=bare-except
- return []
-
- func = parts[0]
- _answer = None
-
- if func == 'min':
- _answer = min(args)
- elif func == 'max':
- _answer = max(args)
- elif func == 'avg':
- _answer = sum(args) / len(args)
- elif func == 'sum':
- _answer = sum(args)
- elif func == 'prod':
- _answer = reduce(mul, args, 1)
-
- if _answer is None:
- return []
-
- return [{'answer': str(_answer)}]
-
-
-# required answerer function
-# returns information about the answerer
-def self_info():
- return {
- 'name': gettext('Statistics functions'),
- 'description': gettext('Compute {functions} of the arguments').format(functions='/'.join(keywords)),
- 'examples': ['avg 123 548 2.04 24.2'],
- }