4 Commits

Author SHA1 Message Date
Markus Heiser
06e4f4f758 [mod] drop searxng_extra/standalone_searx.py (#5256)
This is an old and grumpy hack / SearXNG is a Flask application with
client/server structure, which can't be turned into a command line tool the way
it was done here.

Maintaining this hack is becoming increasingly complex the more we try to
remodel the core code, which is why we should now remove the hack from SearXNG.

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2025-10-01 07:34:53 +02:00
Markus Heiser
748b521ac6 [fix] searx/results.py - TypeError: object of type 'NoneType' has no len()
In some engines, under certain circumstances, the content field can also have
the value ``None``; in these cases, a length check results in an exception::

    File "/usr/local/searxng/searx/results.py", line 360, in merge_two_main_results
        if len(other.content) > len(origin.content):
           ^^^^^^^^^^^^^^^^^^
    TypeError: object of type 'NoneType' has no len()

[1] https://github.com/searxng/searxng/issues/5250#issuecomment-3352863488

Reported-by: @scross01 [1]
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2025-10-01 07:13:10 +02:00
Markus Heiser
e16b6cb148 [fix] JSON format: serialization of the result-types
The ``JSONEncoder`` (``format="json"``) must perform a conversion to the
built-in types for the ``msgspec.Struct``::

    if isinstance(o, msgspec.Struct):
        return msgspec.to_builtins(o)

The result types are already of type ``msgspec.Struct``, so they can be
converted into built-in types.

The field types (in the result type) that were not yet of type ``msgspec.Struct``
have been converted to::

    searx.weather.GeoLocation@dataclass -> msgspec.Struct
    searx.weather.DateTime              -> msgspec.Struct
    searx.weather.Temperature           -> msgspec.Struct
    searx.weather.PressureUnits         -> msgspec.Struct
    searx.weather.WindSpeed             -> msgspec.Struct
    searx.weather.RelativeHumidity      -> msgspec.Struct
    searx.weather.Compass               -> msgspec.Struct

BTW: Wherever it seemed sensible, the typing was also modernized in the modified
files.

Closes: https://github.com/searxng/searxng/issues/5250
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2025-10-01 07:13:10 +02:00
Ivan Gabaldon
41e0f2abf0 [doc] dev - cleanup asdf remnants and add a note about mise-en-place (#5264)
Notes on the developer environment with MISE have been added to the Quickstart
Guide.  We migrated `.tool-versions` to replace it with `mise` [1], so we can
now remove `asdf` related documentation.

[1] https://github.com/searxng/searxng/pull/5253

Co-authored-by: Markus Heiser <markus.heiser@darmarit.de>
2025-10-01 06:29:07 +02:00
15 changed files with 162 additions and 440 deletions

View File

@@ -7,7 +7,6 @@ Developer documentation
quickstart
commits
rtm_asdf
contribution_guide
extended_types
engines/index

View File

@@ -23,6 +23,24 @@ Don't hesitate, just clone SearXNG's sources and start hacking right now ..
git clone https://github.com/searxng/searxng.git searxng
For the developer environment, `mise en place`_ is recommended:
.. code:: sh
$ curl https://mise.run | sh
The tools required for a developer environment are provided via `mise.toml`_,
trust the project:
.. code:: sh
$ mise trust
.. _mise.toml:
https://github.com/searxng/searxng/blob/master/mise.toml
.. _mise en place:
https://mise.jdx.dev/getting-started.html
Here is how a minimal workflow looks like:
1. *start* hacking

View File

@@ -1,121 +0,0 @@
==================
Runtime Management
==================
The runtimes are managed with asdf and are activated in this project via the
`.tool-versions <.tool-versions>`_. If you have not yet installed asdf_, then
chapter :ref:`introduce asdf` may be of help to you.
.. contents::
:depth: 2
:local:
:backlinks: entry
Get started
===========
If you have asdf installed you can install the runtimes of this project by:
.. code:: bash
$ cd /path/to/searxng
$ asdf install # will install runtimes listed in .tool-versions
...
Manage Versions
===============
If you want to perform a ``test`` with special runtime versions of nodejs,
python or shellcheck, you can patch the ``.tool-versions``:
.. code:: diff
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,2 +1,2 @@
-python 3.12.0
-shellcheck 0.9.0
+python 3.11.6
+shellcheck 0.8.0
To install use ``asdf install`` again. If the runtime tools have changed, any
existing (nodejs and python) environments should be cleaned up with a ``make
clean``.
.. code:: bash
$ asdf install
...
$ make clean test
.. _introduce asdf:
Introduce asdf
==============
To `download asdf`_ and `install asdf`_:
.. code:: bash
$ git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch <version>
$ echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
$ echo '. "$HOME/.asdf/completions/asdf.bash"' >> ~/.bashrc
Start a new shell and try to `install plugins`_:
.. code:: bash
$ asdf plugin-list-all | grep -E '(golang|python|nodejs|shellcheck).git'
golang https://github.com/asdf-community/asdf-golang.git
nodejs https://github.com/asdf-vm/asdf-nodejs.git
python https://github.com/danhper/asdf-python.git
shellcheck https://github.com/luizm/asdf-shellcheck.git
$ asdf plugin add golang https://github.com/asdf-community/asdf-golang.git
$ asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git
$ asdf plugin add python https://github.com/danhper/asdf-python.git
$ asdf plugin add shellcheck https://github.com/luizm/asdf-shellcheck.git
Each plugin has dependencies, to compile runtimes visit the URLs from above and
look out for the dependencies you need to install on your OS, on Debian for the
runtimes listed above you will need:
.. code:: bash
$ sudo apt update
$ sudo apt install \
dirmngr gpg curl gawk coreutils build-essential libssl-dev zlib1g-dev \
libbz2-dev libreadline-dev libsqlite3-dev \
libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev
With dependencies installed you can install/compile runtimes:
.. code:: bash
$ asdf install golang latest
$ asdf install nodejs latest
$ asdf install python latest
$ asdf install shellcheck latest
Python will be compiled and will take a while.
In the repository the version is defined in `.tool-versions`_. Outside the
repository, its recommended that the runtime should use the versions of the OS
(`Fallback to System Version`_) / if not already done register the system
versions global:
.. code:: bash
$ cd /
$ asdf global golang system
$ asdf global nodejs system
$ asdf global python system
$ asdf global shellcheck system
.. _asdf: https://asdf-vm.com/
.. _download asdf: https://asdf-vm.com/guide/getting-started.html#_2-download-asdf
.. _install asdf: https://asdf-vm.com/guide/getting-started.html#_3-install-asdf
.. _install plugins: https://asdf-vm.com/guide/getting-started.html#install-the-plugin
.. _Fallback to System Version: https://asdf-vm.com/manage/versions.html#fallback-to-system-version

View File

@@ -11,4 +11,3 @@ developers.
:maxdepth: 2
update
standalone_searx.py

View File

@@ -1,9 +0,0 @@
.. _standalone_searx.py:
=====================================
``searxng_extra/standalone_searx.py``
=====================================
.. automodule:: searxng_extra.standalone_searx
:members:

View File

@@ -76,12 +76,12 @@ def _weather_data(location: weather.GeoLocation, data: dict[str, t.Any]):
return EngineResults.types.WeatherAnswer.Item(
location=location,
temperature=weather.Temperature(unit="°C", value=data['temperature']),
temperature=weather.Temperature(val=data['temperature'], unit="°C"),
condition=WEATHERKIT_TO_CONDITION[data["conditionCode"]],
feels_like=weather.Temperature(unit="°C", value=data['temperatureApparent']),
feels_like=weather.Temperature(val=data['temperatureApparent'], unit="°C"),
wind_from=weather.Compass(data["windDirection"]),
wind_speed=weather.WindSpeed(data["windSpeed"], unit="mi/h"),
pressure=weather.Pressure(data["pressure"], unit="hPa"),
wind_speed=weather.WindSpeed(val=data["windSpeed"], unit="mi/h"),
pressure=weather.Pressure(val=data["pressure"], unit="hPa"),
humidity=weather.RelativeHumidity(data["humidity"] * 100),
cloud_cover=data["cloudCover"] * 100,
)

View File

@@ -1,6 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Open Meteo (weather)"""
import typing as t
from urllib.parse import urlencode
from datetime import datetime
@@ -106,16 +108,16 @@ WMO_TO_CONDITION: dict[int, weather.WeatherConditionType] = {
}
def _weather_data(location: weather.GeoLocation, data: dict):
def _weather_data(location: weather.GeoLocation, data: dict[str, t.Any]):
return WeatherAnswer.Item(
location=location,
temperature=weather.Temperature(unit="°C", value=data["temperature_2m"]),
temperature=weather.Temperature(val=data["temperature_2m"], unit="°C"),
condition=WMO_TO_CONDITION[data["weather_code"]],
feels_like=weather.Temperature(unit="°C", value=data["apparent_temperature"]),
feels_like=weather.Temperature(val=data["apparent_temperature"], unit="°C"),
wind_from=weather.Compass(data["wind_direction_10m"]),
wind_speed=weather.WindSpeed(data["wind_speed_10m"], unit="km/h"),
pressure=weather.Pressure(data["pressure_msl"], unit="hPa"),
wind_speed=weather.WindSpeed(val=data["wind_speed_10m"], unit="km/h"),
pressure=weather.Pressure(val=data["pressure_msl"], unit="hPa"),
humidity=weather.RelativeHumidity(data["relative_humidity_2m"]),
cloud_cover=data["cloud_cover"],
)

View File

@@ -67,7 +67,7 @@ def construct_body(result):
)
thumbnail = pdbe_preview_url.format(pdb_id=result['pdb_id'])
except KeyError:
content = None
content = ""
thumbnail = None
# construct url for preview image

View File

@@ -1,6 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""wttr.in (weather forecast service)"""
import typing as t
from urllib.parse import quote
from datetime import datetime
@@ -80,19 +82,19 @@ def request(query, params):
return params
def _weather_data(location: weather.GeoLocation, data: dict):
def _weather_data(location: weather.GeoLocation, data: dict[str, t.Any]):
# the naming between different data objects is inconsitent, thus temp_C and
# tempC are possible
tempC: float = data.get("temp_C") or data.get("tempC") # type: ignore
return WeatherAnswer.Item(
location=location,
temperature=weather.Temperature(unit="°C", value=tempC),
temperature=weather.Temperature(val=tempC, unit="°C"),
condition=WWO_TO_CONDITION[data["weatherCode"]],
feels_like=weather.Temperature(unit="°C", value=data["FeelsLikeC"]),
feels_like=weather.Temperature(val=data["FeelsLikeC"], unit="°C"),
wind_from=weather.Compass(int(data["winddirDegree"])),
wind_speed=weather.WindSpeed(data["windspeedKmph"], unit="km/h"),
pressure=weather.Pressure(data["pressure"], unit="hPa"),
wind_speed=weather.WindSpeed(val=data["windspeedKmph"], unit="km/h"),
pressure=weather.Pressure(val=data["pressure"], unit="hPa"),
humidity=weather.RelativeHumidity(data["humidity"]),
cloud_cover=data["cloudcover"],
)

View File

@@ -1,7 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# pylint: disable=too-few-public-methods,missing-module-docstring
__all__ = ["PluginInfo", "Plugin", "PluginCfg", "PluginStorage"]
import abc
@@ -9,16 +8,17 @@ import importlib
import inspect
import logging
import re
import typing
from collections.abc import Sequence
import typing as t
from collections.abc import Generator
from dataclasses import dataclass, field
from searx.extended_types import SXNG_Request
from searx.result_types import Result
if typing.TYPE_CHECKING:
if t.TYPE_CHECKING:
from searx.search import SearchWithPlugins
from searx.result_types import Result, EngineResults, LegacyResult # pyright: ignore[reportPrivateLocalImportUsage]
import flask
log: logging.Logger = logging.getLogger("searx.plugins")
@@ -42,7 +42,7 @@ class PluginInfo:
description: str
"""Short description of the *answerer*."""
preference_section: typing.Literal["general", "ui", "privacy", "query"] | None = "general"
preference_section: t.Literal["general", "ui", "privacy", "query"] | None = "general"
"""Section (tab/group) in the preferences where this plugin is shown to the
user.
@@ -71,7 +71,7 @@ class Plugin(abc.ABC):
id: str = ""
"""The ID (suffix) in the HTML form."""
active: typing.ClassVar[bool]
active: t.ClassVar[bool]
"""Plugin is enabled/disabled by default (:py:obj:`PluginCfg.active`)."""
keywords: list[str] = []
@@ -109,7 +109,7 @@ class Plugin(abc.ABC):
raise ValueError(f"plugin ID {self.id} contains invalid character (use lowercase ASCII)")
if not getattr(self, "log", None):
pkg_name = inspect.getmodule(self.__class__).__package__ # type: ignore
pkg_name = inspect.getmodule(self.__class__).__package__ # pyright: ignore[reportOptionalMemberAccess]
self.log = logging.getLogger(f"{pkg_name}.{self.id}")
def __hash__(self) -> int:
@@ -120,7 +120,7 @@ class Plugin(abc.ABC):
return id(self)
def __eq__(self, other: typing.Any):
def __eq__(self, other: t.Any):
"""py:obj:`Plugin` objects are equal if the hash values of the two
objects are equal."""
@@ -146,7 +146,7 @@ class Plugin(abc.ABC):
"""
return True
def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: "Result") -> bool:
"""Runs for each result of each engine and returns a boolean:
- ``True`` to keep the result
@@ -166,7 +166,9 @@ class Plugin(abc.ABC):
"""
return True
def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None | Sequence[Result]:
def post_search(
self, request: SXNG_Request, search: "SearchWithPlugins"
) -> "None | list[Result | LegacyResult] | EngineResults":
"""Runs AFTER the search request. Can return a list of
:py:obj:`Result <searx.result_types._base.Result>` objects to be added to the
final result list."""
@@ -196,7 +198,7 @@ class PluginStorage:
def __init__(self):
self.plugin_list = set()
def __iter__(self):
def __iter__(self) -> Generator[Plugin]:
yield from self.plugin_list
def __len__(self):
@@ -207,7 +209,7 @@ class PluginStorage:
return [p.info for p in self.plugin_list]
def load_settings(self, cfg: dict[str, dict[str, typing.Any]]):
def load_settings(self, cfg: dict[str, dict[str, t.Any]]):
"""Load plugins configured in SearXNG's settings :ref:`settings
plugins`."""
@@ -262,7 +264,7 @@ class PluginStorage:
break
return ret
def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: "Result") -> bool:
ret = True
for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:

View File

@@ -1,12 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# pylint: disable=missing-module-docstring
from __future__ import annotations
import typing as t
import datetime
from flask_babel import gettext # type: ignore
from flask_babel import gettext
from searx.result_types import EngineResults
from searx.weather import DateTime, GeoLocation
@@ -53,13 +52,13 @@ class SXNGPlugin(Plugin):
search_term = " ".join(query_parts).strip()
if not search_term:
date_time = DateTime(time=datetime.datetime.now())
date_time = DateTime(datetime.datetime.now())
results.add(results.types.Answer(answer=date_time.l10n()))
return results
geo = GeoLocation.by_query(search_term=search_term)
if geo:
date_time = DateTime(time=datetime.datetime.now(tz=geo.zoneinfo))
date_time = DateTime(datetime.datetime.now(tz=geo.zoneinfo))
tz_name = geo.timezone.replace('_', ' ')
results.add(
results.types.Answer(

View File

@@ -357,12 +357,12 @@ def merge_two_infoboxes(origin: LegacyResult, other: LegacyResult):
def merge_two_main_results(origin: MainResult | LegacyResult, other: MainResult | LegacyResult):
"""Merges the values from ``other`` into ``origin``."""
if len(other.content) > len(origin.content):
if len(other.content or "") > len(origin.content or ""):
# use content with more text
origin.content = other.content
# use title with more text
if len(other.title) > len(origin.title):
if len(other.title or "") > len(origin.title or ""):
origin.title = other.title
# merge all result's parameters not found in origin

View File

@@ -14,11 +14,10 @@ __all__ = [
"GeoLocation",
]
import typing
import typing as t
import base64
import datetime
import dataclasses
import zoneinfo
from urllib.parse import quote_plus
@@ -27,7 +26,8 @@ import babel
import babel.numbers
import babel.dates
import babel.languages
import flask_babel
import flask_babel # pyright: ignore[reportMissingTypeStubs]
import msgspec
from searx import network
from searx.cache import ExpireCache, ExpireCacheCfg
@@ -120,8 +120,7 @@ def symbol_url(condition: "WeatherConditionType") -> str | None:
return data_url
@dataclasses.dataclass
class GeoLocation:
class GeoLocation(msgspec.Struct, kw_only=True):
"""Minimal implementation of Geocoding."""
# The type definition was based on the properties from the geocoding API of
@@ -176,6 +175,8 @@ class GeoLocation:
ctx = "weather_geolocation_by_query"
cache = get_WEATHER_DATA_CACHE()
# {'name': 'Berlin', 'latitude': 52.52437, 'longitude': 13.41053,
# 'elevation': 74.0, 'country_code': 'DE', 'timezone': 'Europe/Berlin'}
geo_props = cache.get(search_term, ctx=ctx)
if not geo_props:
@@ -194,15 +195,14 @@ class GeoLocation:
if not results:
raise ValueError(f"unknown geo location: '{search_term}'")
location = results[0]
return {field.name: location[field.name] for field in dataclasses.fields(cls)}
return {field_name: location[field_name] for field_name in cls.__struct_fields__}
DateTimeFormats = typing.Literal["full", "long", "medium", "short"]
DateTimeLocaleTypes = typing.Literal["UI"]
DateTimeFormats = t.Literal["full", "long", "medium", "short"]
DateTimeLocaleTypes = t.Literal["UI"]
@typing.final
class DateTime:
class DateTime(msgspec.Struct):
"""Class to represent date & time. Essentially, it is a wrapper that
conveniently combines :py:obj:`datetime.datetime` and
:py:obj:`babel.dates.format_datetime`. A conversion of time zones is not
@@ -216,8 +216,7 @@ class DateTime:
as the value for the ``locale``.
"""
def __init__(self, time: datetime.datetime):
self.datetime = time
datetime: datetime.datetime
def __str__(self):
return self.l10n()
@@ -252,36 +251,35 @@ class DateTime:
return babel.dates.format_date(self.datetime, format=fmt, locale=locale)
@typing.final
class Temperature:
TemperatureUnits: t.TypeAlias = t.Literal["°C", "°F", "K"]
class Temperature(msgspec.Struct, kw_only=True):
"""Class for converting temperature units and for string representation of
measured values."""
si_name = "Q11579"
val: float
unit: TemperatureUnits
Units = typing.Literal["°C", "°F", "K"]
"""Supported temperature units."""
si_name: t.ClassVar[str] = "Q11579"
units: t.ClassVar[list[str]] = list(t.get_args(TemperatureUnits))
units = list(typing.get_args(Units))
def __init__(self, value: float, unit: Units):
if unit not in self.units:
raise ValueError(f"invalid unit: {unit}")
self.si: float = convert_to_si( # pylint: disable=invalid-name
si_name=self.si_name,
symbol=unit,
value=value,
)
def __post_init__(self):
if self.unit not in self.units:
raise ValueError(f"invalid unit: {self.unit}")
def __str__(self):
return self.l10n()
def value(self, unit: Units) -> float:
return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
def value(self, unit: TemperatureUnits) -> float:
if unit == self.unit:
return self.val
si_val = convert_to_si(si_name=self.si_name, symbol=self.unit, value=self.val)
return convert_from_si(si_name=self.si_name, symbol=unit, value=si_val)
def l10n(
self,
unit: Units | None = None,
unit: TemperatureUnits | None = None,
locale: babel.Locale | GeoLocation | None = None,
template: str = "{value} {unit}",
num_pattern: str = "#,##0",
@@ -320,33 +318,35 @@ class Temperature:
return template.format(value=val_str, unit=unit)
@typing.final
class Pressure:
PressureUnits: t.TypeAlias = t.Literal["Pa", "hPa", "cm Hg", "bar"]
class Pressure(msgspec.Struct, kw_only=True):
"""Class for converting pressure units and for string representation of
measured values."""
si_name = "Q44395"
val: float
unit: PressureUnits
Units = typing.Literal["Pa", "hPa", "cm Hg", "bar"]
"""Supported units."""
si_name: t.ClassVar[str] = "Q44395"
units: t.ClassVar[list[str]] = list(t.get_args(PressureUnits))
units = list(typing.get_args(Units))
def __init__(self, value: float, unit: Units):
if unit not in self.units:
raise ValueError(f"invalid unit: {unit}")
# pylint: disable=invalid-name
self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
def __post_init__(self):
if self.unit not in self.units:
raise ValueError(f"invalid unit: {self.unit}")
def __str__(self):
return self.l10n()
def value(self, unit: Units) -> float:
return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
def value(self, unit: PressureUnits) -> float:
if unit == self.unit:
return self.val
si_val = convert_to_si(si_name=self.si_name, symbol=self.unit, value=self.val)
return convert_from_si(si_name=self.si_name, symbol=unit, value=si_val)
def l10n(
self,
unit: Units | None = None,
unit: PressureUnits | None = None,
locale: babel.Locale | GeoLocation | None = None,
template: str = "{value} {unit}",
num_pattern: str = "#,##0",
@@ -363,8 +363,10 @@ class Pressure:
return template.format(value=val_str, unit=unit)
@typing.final
class WindSpeed:
WindSpeedUnits: t.TypeAlias = t.Literal["m/s", "km/h", "kn", "mph", "mi/h", "Bft"]
class WindSpeed(msgspec.Struct, kw_only=True):
"""Class for converting speed or velocity units and for string
representation of measured values.
@@ -375,28 +377,28 @@ class WindSpeed:
(55.6 m/s)
"""
si_name = "Q182429"
val: float
unit: WindSpeedUnits
Units = typing.Literal["m/s", "km/h", "kn", "mph", "mi/h", "Bft"]
"""Supported units."""
si_name: t.ClassVar[str] = "Q182429"
units: t.ClassVar[list[str]] = list(t.get_args(WindSpeedUnits))
units = list(typing.get_args(Units))
def __init__(self, value: float, unit: Units):
if unit not in self.units:
raise ValueError(f"invalid unit: {unit}")
# pylint: disable=invalid-name
self.si: float = convert_to_si(si_name=self.si_name, symbol=unit, value=value)
def __post_init__(self):
if self.unit not in self.units:
raise ValueError(f"invalid unit: {self.unit}")
def __str__(self):
return self.l10n()
def value(self, unit: Units) -> float:
return convert_from_si(si_name=self.si_name, symbol=unit, value=self.si)
def value(self, unit: WindSpeedUnits) -> float:
if unit == self.unit:
return self.val
si_val = convert_to_si(si_name=self.si_name, symbol=self.unit, value=self.val)
return convert_from_si(si_name=self.si_name, symbol=unit, value=si_val)
def l10n(
self,
unit: Units | None = None,
unit: WindSpeedUnits | None = None,
locale: babel.Locale | GeoLocation | None = None,
template: str = "{value} {unit}",
num_pattern: str = "#,##0",
@@ -413,23 +415,23 @@ class WindSpeed:
return template.format(value=val_str, unit=unit)
@typing.final
class RelativeHumidity:
RelativeHumidityUnits: t.TypeAlias = t.Literal["%"]
class RelativeHumidity(msgspec.Struct):
"""Amount of relative humidity in the air. The unit is ``%``"""
Units = typing.Literal["%"]
"""Supported unit."""
val: float
units = list(typing.get_args(Units))
def __init__(self, humidity: float):
self.humidity = humidity
# there exists only one unit (%) --> set "%" as the final value (constant)
unit: t.ClassVar["t.Final[RelativeHumidityUnits]"] = "%"
units: t.ClassVar[list[str]] = list(t.get_args(RelativeHumidityUnits))
def __str__(self):
return self.l10n()
def value(self) -> float:
return self.humidity
return self.val
def l10n(
self,
@@ -447,45 +449,50 @@ class RelativeHumidity:
return template.format(value=val_str, unit=unit)
@typing.final
class Compass:
CompassPoint: t.TypeAlias = t.Literal[
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
]
"""Compass point type definition"""
CompassUnits: t.TypeAlias = t.Literal["°", "Point"]
class Compass(msgspec.Struct):
"""Class for converting compass points and azimuth values (360°)"""
Units = typing.Literal["°", "Point"]
val: "float | int | CompassPoint"
unit: CompassUnits = "°"
Point = typing.Literal[
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
]
"""Compass point type definition"""
TURN = 360.0
TURN: t.ClassVar[float] = 360.0
"""Full turn (360°)"""
POINTS = list(typing.get_args(Point))
POINTS: t.ClassVar[list[CompassPoint]] = list(t.get_args(CompassPoint))
"""Compass points."""
RANGE = TURN / len(POINTS)
RANGE: t.ClassVar[float] = TURN / len(POINTS)
"""Angle sector of a compass point"""
def __init__(self, azimuth: float | int | Point):
if isinstance(azimuth, str):
if azimuth not in self.POINTS:
raise ValueError(f"Invalid compass point: {azimuth}")
azimuth = self.POINTS.index(azimuth) * self.RANGE
self.azimuth = azimuth % self.TURN
def __post_init__(self):
if isinstance(self.val, str):
if self.val not in self.POINTS:
raise ValueError(f"Invalid compass point: {self.val}")
self.val = self.POINTS.index(self.val) * self.RANGE
self.val = self.val % self.TURN
self.unit = "°"
def __str__(self):
return self.l10n()
def value(self, unit: Units):
if unit == "Point":
return self.point(self.azimuth)
def value(self, unit: CompassUnits):
if unit == "Point" and isinstance(self.val, float):
return self.point(self.val)
if unit == "°":
return self.azimuth
return self.val
raise ValueError(f"unknown unit: {unit}")
@classmethod
def point(cls, azimuth: float | int) -> Point:
def point(cls, azimuth: float | int) -> CompassPoint:
"""Returns the compass point to an azimuth value."""
azimuth = azimuth % cls.TURN
# The angle sector of a compass point starts 1/2 sector range before
@@ -496,7 +503,7 @@ class Compass:
def l10n(
self,
unit: Units = "Point",
unit: CompassUnits = "Point",
locale: babel.Locale | GeoLocation | None = None,
template: str = "{value}{unit}",
num_pattern: str = "#,##0",
@@ -514,7 +521,7 @@ class Compass:
return template.format(value=val_str, unit=unit)
WeatherConditionType = typing.Literal[
WeatherConditionType = t.Literal[
# The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel")
"clear sky",
"partly cloudy",
@@ -632,7 +639,7 @@ YR_WEATHER_SYMBOL_MAP = {
if __name__ == "__main__":
# test: fetch all symbols of the type catalog ..
for c in typing.get_args(WeatherConditionType):
for c in t.get_args(WeatherConditionType):
symbol_url(condition=c)
_cache = get_WEATHER_DATA_CACHE()

View File

@@ -16,6 +16,7 @@ from typing import Iterable, List, Tuple, TYPE_CHECKING
from io import StringIO
from codecs import getincrementalencoder
import msgspec
from flask_babel import gettext, format_date # type: ignore
from searx import logger, get_setting
@@ -147,6 +148,8 @@ def write_csv_response(csv: CSVWriter, rc: "ResultContainer") -> None: # pylint
class JSONEncoder(json.JSONEncoder): # pylint: disable=missing-class-docstring
def default(self, o):
if isinstance(o, msgspec.Struct):
return msgspec.to_builtins(o)
if isinstance(o, datetime):
return o.isoformat()
if isinstance(o, timedelta):

View File

@@ -1,179 +0,0 @@
#!/usr/bin/env python
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Script to run SearXNG from terminal.
DON'T USE THIS SCRIPT!!
.. danger::
Be warned, using the ``standalone_searx.py`` won't give you privacy!
On the contrary, this script behaves like a SearXNG server: your IP is
exposed and tracked by all active engines (google, bing, qwant, ... ), with
every query!
.. note::
This is an old and grumpy hack / SearXNG is a Flask application with
client/server structure, which can't be turned into a command line tool the
way it was done here.
Getting categories without initiate the engine will only return `['general']`
>>> import searx.engines
... list(searx.engines.categories.keys())
['general']
>>> import searx.search
... searx.search.initialize()
... list(searx.engines.categories.keys())
['general', 'it', 'science', 'images', 'news', 'videos', 'music', 'files', 'social media', 'map']
Example to use this script:
.. code:: bash
$ python3 searxng_extra/standalone_searx.py rain
""" # pylint: disable=line-too-long
import argparse
import sys
from datetime import datetime
from json import dumps
from typing import Any, Dict, List, Optional
import searx
import searx.preferences
import searx.query
import searx.search
import searx.search.models
import searx.webadapter
from searx.search.processors import PROCESSORS
EngineCategoriesVar = Optional[List[str]]
def get_search_query(
args: argparse.Namespace, engine_categories: EngineCategoriesVar = None
) -> searx.search.models.SearchQuery:
"""Get search results for the query"""
if engine_categories is None:
engine_categories = list(searx.engines.categories.keys())
try:
category = args.category.decode('utf-8')
except AttributeError:
category = args.category
form = {
"q": args.query,
"categories": category,
"pageno": str(args.pageno),
"language": args.lang,
"time_range": args.timerange,
}
preferences = searx.preferences.Preferences(['simple'], engine_categories, searx.engines.engines, [])
preferences.key_value_settings['safesearch'].parse(args.safesearch)
search_query = searx.webadapter.get_search_query_from_webapp(preferences, form)[0]
return search_query
def no_parsed_url(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Remove parsed url from dict."""
for result in results:
del result['parsed_url']
return results
def json_serial(obj: Any) -> Any:
"""JSON serializer for objects not serializable by default json code.
:raise TypeError: raised when **obj** is not serializable
"""
if isinstance(obj, datetime):
serial = obj.isoformat()
return serial
if isinstance(obj, bytes):
return obj.decode('utf8')
if isinstance(obj, set):
return list(obj)
raise TypeError("Type ({}) not serializable".format(type(obj)))
def to_dict(search_query: searx.search.models.SearchQuery) -> Dict[str, Any]:
"""Get result from parsed arguments."""
result_container = searx.search.Search(search_query).search()
result_container_json = {
"search": {
"q": search_query.query,
"pageno": search_query.pageno,
"lang": search_query.lang,
"safesearch": search_query.safesearch,
"timerange": search_query.time_range,
},
"results": no_parsed_url(result_container.get_ordered_results()),
"infoboxes": result_container.infoboxes,
"suggestions": list(result_container.suggestions),
"answers": list(result_container.answers),
"paging": result_container.paging,
"number_of_results": result_container.number_of_results,
}
return result_container_json
def parse_argument(
args: Optional[List[str]] = None, category_choices: EngineCategoriesVar = None
) -> argparse.Namespace:
"""Parse command line.
:raise SystemExit: Query argument required on `args`
Examples:
>>> import importlib
... # load module
... spec = importlib.util.spec_from_file_location(
... 'utils.standalone_searx', 'utils/standalone_searx.py')
... sas = importlib.util.module_from_spec(spec)
... spec.loader.exec_module(sas)
... sas.parse_argument()
usage: ptipython [-h] [--category [{general}]] [--lang [LANG]] [--pageno [PAGENO]] [--safesearch [{0,1,2}]] [--timerange [{day,week,month,year}]]
query
SystemExit: 2
>>> sas.parse_argument(['rain'])
Namespace(category='general', lang='all', pageno=1, query='rain', safesearch='0', timerange=None)
""" # noqa: E501
if not category_choices:
category_choices = list(searx.engines.categories.keys())
parser = argparse.ArgumentParser(description='Standalone searx.')
parser.add_argument('query', type=str, help='Text query')
parser.add_argument(
'--category', type=str, nargs='?', choices=category_choices, default='general', help='Search category'
)
parser.add_argument('--lang', type=str, nargs='?', default='all', help='Search language')
parser.add_argument('--pageno', type=int, nargs='?', default=1, help='Page number starting from 1')
parser.add_argument(
'--safesearch',
type=str,
nargs='?',
choices=['0', '1', '2'],
default='0',
help='Safe content filter from none to strict',
)
parser.add_argument(
'--timerange', type=str, nargs='?', choices=['day', 'week', 'month', 'year'], help='Filter by time range'
)
return parser.parse_args(args)
if __name__ == '__main__':
settings_engines = searx.settings['engines']
searx.search.load_engines(settings_engines)
engine_cs = list(searx.engines.categories.keys())
prog_args = parse_argument(category_choices=engine_cs)
searx.search.initialize_network(settings_engines, searx.settings['outgoing'])
searx.search.check_network_configuration()
searx.search.initialize_metrics([engine['name'] for engine in settings_engines])
PROCESSORS.init(settings_engines)
search_q = get_search_query(prog_args, engine_categories=engine_cs)
res_dict = to_dict(search_q)
sys.stdout.write(dumps(res_dict, sort_keys=True, indent=4, ensure_ascii=False, default=json_serial))