diff options
Diffstat (limited to 'searx/answerers')
| -rw-r--r-- | searx/answerers/__init__.py | 70 | ||||
| -rw-r--r-- | searx/answerers/_core.py | 169 | ||||
| -rw-r--r-- | searx/answerers/random.py | 80 | ||||
| -rw-r--r-- | searx/answerers/random/__init__.py | 2 | ||||
| -rw-r--r-- | searx/answerers/random/answerer.py | 79 | ||||
| -rw-r--r-- | searx/answerers/statistics.py | 64 | ||||
| -rw-r--r-- | searx/answerers/statistics/__init__.py | 2 | ||||
| -rw-r--r-- | searx/answerers/statistics/answerer.py | 53 |
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'], - } |