.oO SearXNG Developer Documentation Oo.
Loading...
Searching...
No Matches
_core.py
Go to the documentation of this file.
1# SPDX-License-Identifier: AGPL-3.0-or-later
2# pylint: disable=too-few-public-methods,missing-module-docstring
3
4from __future__ import annotations
5
6__all__ = ["PluginInfo", "Plugin", "PluginStorage"]
7
8import abc
9import importlib
10import logging
11import pathlib
12import types
13import typing
14import warnings
15
16from dataclasses import dataclass, field
17
18import flask
19
20import searx
21from searx.utils import load_module
22from searx.extended_types import SXNG_Request
23from searx.result_types import Result
24
25
26if typing.TYPE_CHECKING:
27 from searx.search import SearchWithPlugins
28
29
30_default = pathlib.Path(__file__).parent
31log: logging.Logger = logging.getLogger("searx.plugins")
32
33
34@dataclass
36 """Object that holds informations about a *plugin*, these infos are shown to
37 the user in the Preferences menu.
38
39 To be able to translate the information into other languages, the text must
40 be written in English and translated with :py:obj:`flask_babel.gettext`.
41 """
42
43 id: str
44 """The ID-selector in HTML/CSS `#<id>`."""
45
46 name: str
47 """Name of the *plugin*."""
48
49 description: str
50 """Short description of the *answerer*."""
51
52 preference_section: typing.Literal["general", "ui", "privacy", "query"] | None = "general"
53 """Section (tab/group) in the preferences where this plugin is shown to the
54 user.
55
56 The value ``query`` is reserved for plugins that are activated via a
57 *keyword* as part of a search query, see:
58
59 - :py:obj:`PluginInfo.examples`
60 - :py:obj:`Plugin.keywords`
61
62 Those plugins are shown in the preferences in tab *Special Queries*.
63 """
64
65 examples: list[str] = field(default_factory=list)
66 """List of short examples of the usage / of query terms."""
67
68 keywords: list[str] = field(default_factory=list)
69 """See :py:obj:`Plugin.keywords`"""
70
71
72class Plugin(abc.ABC):
73 """Abstract base class of all Plugins."""
74
75 id: typing.ClassVar[str]
76 """The ID (suffix) in the HTML form."""
77
78 default_on: typing.ClassVar[bool]
79 """Plugin is enabled/disabled by default."""
80
81 keywords: list[str] = []
82 """Keywords in the search query that activate the plugin. The *keyword* is
83 the first word in a search query. If a plugin should be executed regardless
84 of the search query, the list of keywords should be empty (which is also the
85 default in the base class for Plugins)."""
86
87 log: logging.Logger
88 """A logger object, is automatically initialized when calling the
89 constructor (if not already set in the subclass)."""
90
91 info: PluginInfo
92 """Informations about the *plugin*, see :py:obj:`PluginInfo`."""
93
94 def __init__(self) -> None:
95 super().__init__()
96
97 for attr in ["id", "default_on"]:
98 if getattr(self, attr, None) is None:
99 raise NotImplementedError(f"plugin {self} is missing attribute {attr}")
100
101 if not self.id:
102 self.id = f"{self.__class__.__module__}.{self.__class__.__name__}"
103 if not getattr(self, "log", None):
104 self.log = log.getChild(self.id)
105
106 def __hash__(self) -> int:
107 """The hash value is used in :py:obj:`set`, for example, when an object
108 is added to the set. The hash value is also used in other contexts,
109 e.g. when checking for equality to identify identical plugins from
110 different sources (name collisions)."""
111
112 return id(self)
113
114 def __eq__(self, other):
115 """py:obj:`Plugin` objects are equal if the hash values of the two
116 objects are equal."""
117
118 return hash(self) == hash(other)
119
120 def init(self, app: flask.Flask) -> bool: # pylint: disable=unused-argument
121 """Initialization of the plugin, the return value decides whether this
122 plugin is active or not. Initialization only takes place once, at the
123 time the WEB application is set up. The base methode always returns
124 ``True``, the methode can be overwritten in the inheritances,
125
126 - ``True`` plugin is active
127 - ``False`` plugin is inactive
128 """
129 return True
130
131 # pylint: disable=unused-argument
132 def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
133 """Runs BEFORE the search request and returns a boolean:
134
135 - ``True`` to continue the search
136 - ``False`` to stop the search
137 """
138 return True
139
140 def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
141 """Runs for each result of each engine and returns a boolean:
142
143 - ``True`` to keep the result
144 - ``False`` to remove the result from the result list
145
146 The ``result`` can be modified to the needs.
147
148 .. hint::
149
150 If :py:obj:`Result.url` is modified, :py:obj:`Result.parsed_url` must
151 be changed accordingly:
152
153 .. code:: python
154
155 result["parsed_url"] = urlparse(result["url"])
156 """
157 return True
158
159 def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None | typing.Sequence[Result]:
160 """Runs AFTER the search request. Can return a list of :py:obj:`Result`
161 objects to be added to the final result list."""
162 return
163
164
166 """A wrapper class for legacy *plugins*.
167
168 .. note::
169
170 For internal use only!
171
172 In a module plugin, the follwing names are mapped:
173
174 - `module.query_keywords` --> :py:obj:`Plugin.keywords`
175 - `module.plugin_id` --> :py:obj:`Plugin.id`
176 - `module.logger` --> :py:obj:`Plugin.log`
177 """
178
179 _required_attrs = (("name", str), ("description", str), ("default_on", bool))
180
181 def __init__(self, mod: types.ModuleType):
182 """In case of missing attributes in the module or wrong types are given,
183 a :py:obj:`TypeError` exception is raised."""
184
185 self.module = mod
186 self.id = getattr(self.module, "plugin_id", self.module.__name__)
187 self.log = logging.getLogger(self.module.__name__)
188 self.keywords = getattr(self.module, "query_keywords", [])
189
190 for attr, attr_type in self._required_attrs:
191 if not hasattr(self.module, attr):
192 msg = f"missing attribute {attr}, cannot load plugin"
193 self.log.critical(msg)
194 raise TypeError(msg)
195 if not isinstance(getattr(self.module, attr), attr_type):
196 msg = f"attribute {attr} is not of type {attr_type}"
197 self.log.critical(msg)
198 raise TypeError(msg)
199
200 self.default_on = mod.default_on
202 id=self.id,
203 name=self.module.name,
204 description=self.module.description,
205 preference_section=getattr(self.module, "preference_section", None),
206 examples=getattr(self.module, "query_examples", []),
207 keywords=self.keywords,
208 )
209
210 # monkeypatch module
211 self.module.logger = self.log # type: ignore
212
213 super().__init__()
214
215 def init(self, app: flask.Flask) -> bool:
216 if not hasattr(self.module, "init"):
217 return True
218 return self.module.init(app)
219
220 def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
221 if not hasattr(self.module, "pre_search"):
222 return True
223 return self.module.pre_search(request, search)
224
225 def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
226 if not hasattr(self.module, "on_result"):
227 return True
228 return self.module.on_result(request, search, result)
229
230 def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None | list[Result]:
231 if not hasattr(self.module, "post_search"):
232 return None
233 return self.module.post_search(request, search)
234
235
237 """A storage for managing the *plugins* of SearXNG."""
238
239 plugin_list: set[Plugin]
240 """The list of :py:obj:`Plugins` in this storage."""
241
242 legacy_plugins = [
243 "ahmia_filter",
244 "calculator",
245 "hostnames",
246 "oa_doi_rewrite",
247 "tor_check",
248 "tracker_url_remover",
249 "unit_converter",
250 ]
251 """Internal plugins implemented in the legacy style (as module / deprecated!)."""
252
253 def __init__(self):
254 self.plugin_list = set()
255
256 def __iter__(self):
257
258 yield from self.plugin_list
259
260 def __len__(self):
261 return len(self.plugin_list)
262
263 @property
264 def info(self) -> list[PluginInfo]:
265 return [p.info for p in self.plugin_list]
266
267 def load_builtins(self):
268 """Load plugin modules from:
269
270 - the python packages in :origin:`searx/plugins` and
271 - the external plugins from :ref:`settings plugins`.
272 """
273
274 for f in _default.iterdir():
275
276 if f.name.startswith("_"):
277 continue
278
279 if f.stem not in self.legacy_plugins:
280 self.register_by_fqn(f"searx.plugins.{f.stem}.SXNGPlugin")
281 continue
282
283 # for backward compatibility
284 mod = load_module(f.name, str(f.parent))
285 self.register(ModulePlugin(mod))
286
287 for fqn in searx.get_setting("plugins"): # type: ignore
288 self.register_by_fqn(fqn)
289
290 def register(self, plugin: Plugin):
291 """Register a :py:obj:`Plugin`. In case of name collision (if two
292 plugins have same ID) a :py:obj:`KeyError` exception is raised.
293 """
294
295 if plugin in self.plugin_list:
296 msg = f"name collision '{plugin.id}'"
297 plugin.log.critical(msg)
298 raise KeyError(msg)
299
300 self.plugin_list.add(plugin)
301 plugin.log.debug("plugin has been loaded")
302
303 def register_by_fqn(self, fqn: str):
304 """Register a :py:obj:`Plugin` via its fully qualified class name (FQN).
305 The FQNs of external plugins could be read from a configuration, for
306 example, and registered using this method
307 """
308
309 mod_name, _, obj_name = fqn.rpartition('.')
310 if not mod_name:
311 # for backward compatibility
312 code_obj = importlib.import_module(fqn)
313 else:
314 mod = importlib.import_module(mod_name)
315 code_obj = getattr(mod, obj_name, None)
316
317 if code_obj is None:
318 msg = f"plugin {fqn} is not implemented"
319 log.critical(msg)
320 raise ValueError(msg)
321
322 if isinstance(code_obj, types.ModuleType):
323 # for backward compatibility
324 warnings.warn(
325 f"plugin {fqn} is implemented in a legacy module / migrate to searx.plugins.Plugin", DeprecationWarning
326 )
327 self.register(ModulePlugin(code_obj))
328 return
329
330 self.register(code_obj())
331
332 def init(self, app: flask.Flask) -> None:
333 """Calls the method :py:obj:`Plugin.init` of each plugin in this
334 storage. Depending on its return value, the plugin is removed from
335 *this* storage or not."""
336
337 for plg in self.plugin_list.copy():
338 if not plg.init(app):
339 self.plugin_list.remove(plg)
340
341 def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
342
343 ret = True
344 for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
345 try:
346 ret = bool(plugin.pre_search(request=request, search=search))
347 except Exception: # pylint: disable=broad-except
348 plugin.log.exception("Exception while calling pre_search")
349 continue
350 if not ret:
351 # skip this search on the first False from a plugin
352 break
353 return ret
354
355 def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
356
357 ret = True
358 for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
359 try:
360 ret = bool(plugin.on_result(request=request, search=search, result=result))
361 except Exception: # pylint: disable=broad-except
362 plugin.log.exception("Exception while calling on_result")
363 continue
364 if not ret:
365 # ignore this result item on the first False from a plugin
366 break
367
368 return ret
369
370 def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None:
371 """Extend :py:obj:`search.result_container
372 <searx.results.ResultContainer`> with result items from plugins listed
373 in :py:obj:`search.user_plugins <SearchWithPlugins.user_plugins>`.
374 """
375
376 keyword = None
377 for keyword in search.search_query.query.split():
378 if keyword:
379 break
380
381 for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
382
383 if plugin.keywords:
384 # plugin with keywords: skip plugin if no keyword match
385 if keyword and keyword not in plugin.keywords:
386 continue
387 try:
388 results = plugin.post_search(request=request, search=search) or []
389 except Exception: # pylint: disable=broad-except
390 plugin.log.exception("Exception while calling post_search")
391 continue
392
393 # In case of *plugins* prefix ``plugin:`` is set, see searx.result_types.Result
394 search.result_container.extend(f"plugin: {plugin.id}", results)
bool on_result(self, SXNG_Request request, "SearchWithPlugins" search, Result result)
Definition _core.py:225
bool init(self, flask.Flask app)
Definition _core.py:215
None|list[Result] post_search(self, SXNG_Request request, "SearchWithPlugins" search)
Definition _core.py:230
__init__(self, types.ModuleType mod)
Definition _core.py:181
bool pre_search(self, SXNG_Request request, "SearchWithPlugins" search)
Definition _core.py:220
None post_search(self, SXNG_Request request, "SearchWithPlugins" search)
Definition _core.py:370
bool on_result(self, SXNG_Request request, "SearchWithPlugins" search, Result result)
Definition _core.py:355
bool pre_search(self, SXNG_Request request, "SearchWithPlugins" search)
Definition _core.py:341
register_by_fqn(self, str fqn)
Definition _core.py:303
None init(self, flask.Flask app)
Definition _core.py:332
list[PluginInfo] info(self)
Definition _core.py:264
register(self, Plugin plugin)
Definition _core.py:290
bool on_result(self, SXNG_Request request, "SearchWithPlugins" search, Result result)
Definition _core.py:140
__eq__(self, other)
Definition _core.py:114
None|typing.Sequence[Result] post_search(self, SXNG_Request request, "SearchWithPlugins" search)
Definition _core.py:159
bool pre_search(self, SXNG_Request request, "SearchWithPlugins" search)
Definition _core.py:132
bool init(self, flask.Flask app)
Definition _core.py:120
get_setting(name, default=_unset)
Definition __init__.py:69