[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>
This commit is contained in:
Markus Heiser 2025-09-30 14:00:09 +02:00 committed by Markus Heiser
parent 41e0f2abf0
commit e16b6cb148
7 changed files with 141 additions and 126 deletions

View File

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

View File

@ -1,6 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Open Meteo (weather)""" """Open Meteo (weather)"""
import typing as t
from urllib.parse import urlencode from urllib.parse import urlencode
from datetime import datetime 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( return WeatherAnswer.Item(
location=location, 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"]], 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_from=weather.Compass(data["wind_direction_10m"]),
wind_speed=weather.WindSpeed(data["wind_speed_10m"], unit="km/h"), wind_speed=weather.WindSpeed(val=data["wind_speed_10m"], unit="km/h"),
pressure=weather.Pressure(data["pressure_msl"], unit="hPa"), pressure=weather.Pressure(val=data["pressure_msl"], unit="hPa"),
humidity=weather.RelativeHumidity(data["relative_humidity_2m"]), humidity=weather.RelativeHumidity(data["relative_humidity_2m"]),
cloud_cover=data["cloud_cover"], cloud_cover=data["cloud_cover"],
) )

View File

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

View File

@ -1,7 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
# pylint: disable=too-few-public-methods,missing-module-docstring # pylint: disable=too-few-public-methods,missing-module-docstring
__all__ = ["PluginInfo", "Plugin", "PluginCfg", "PluginStorage"] __all__ = ["PluginInfo", "Plugin", "PluginCfg", "PluginStorage"]
import abc import abc
@ -9,16 +8,17 @@ import importlib
import inspect import inspect
import logging import logging
import re import re
import typing
from collections.abc import Sequence import typing as t
from collections.abc import Generator
from dataclasses import dataclass, field from dataclasses import dataclass, field
from searx.extended_types import SXNG_Request 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.search import SearchWithPlugins
from searx.result_types import Result, EngineResults, LegacyResult # pyright: ignore[reportPrivateLocalImportUsage]
import flask import flask
log: logging.Logger = logging.getLogger("searx.plugins") log: logging.Logger = logging.getLogger("searx.plugins")
@ -42,7 +42,7 @@ class PluginInfo:
description: str description: str
"""Short description of the *answerer*.""" """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 """Section (tab/group) in the preferences where this plugin is shown to the
user. user.
@ -71,7 +71,7 @@ class Plugin(abc.ABC):
id: str = "" id: str = ""
"""The ID (suffix) in the HTML form.""" """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`).""" """Plugin is enabled/disabled by default (:py:obj:`PluginCfg.active`)."""
keywords: list[str] = [] keywords: list[str] = []
@ -109,7 +109,7 @@ class Plugin(abc.ABC):
raise ValueError(f"plugin ID {self.id} contains invalid character (use lowercase ASCII)") raise ValueError(f"plugin ID {self.id} contains invalid character (use lowercase ASCII)")
if not getattr(self, "log", None): 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}") self.log = logging.getLogger(f"{pkg_name}.{self.id}")
def __hash__(self) -> int: def __hash__(self) -> int:
@ -120,7 +120,7 @@ class Plugin(abc.ABC):
return id(self) 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 """py:obj:`Plugin` objects are equal if the hash values of the two
objects are equal.""" objects are equal."""
@ -146,7 +146,7 @@ class Plugin(abc.ABC):
""" """
return True 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: """Runs for each result of each engine and returns a boolean:
- ``True`` to keep the result - ``True`` to keep the result
@ -166,7 +166,9 @@ class Plugin(abc.ABC):
""" """
return True 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 """Runs AFTER the search request. Can return a list of
:py:obj:`Result <searx.result_types._base.Result>` objects to be added to the :py:obj:`Result <searx.result_types._base.Result>` objects to be added to the
final result list.""" final result list."""
@ -196,7 +198,7 @@ class PluginStorage:
def __init__(self): def __init__(self):
self.plugin_list = set() self.plugin_list = set()
def __iter__(self): def __iter__(self) -> Generator[Plugin]:
yield from self.plugin_list yield from self.plugin_list
def __len__(self): def __len__(self):
@ -207,7 +209,7 @@ class PluginStorage:
return [p.info for p in self.plugin_list] 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 """Load plugins configured in SearXNG's settings :ref:`settings
plugins`.""" plugins`."""
@ -262,7 +264,7 @@ class PluginStorage:
break break
return ret 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 ret = True
for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]: 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 # SPDX-License-Identifier: AGPL-3.0-or-later
# pylint: disable=missing-module-docstring # pylint: disable=missing-module-docstring
from __future__ import annotations
import typing as t import typing as t
import datetime import datetime
from flask_babel import gettext # type: ignore from flask_babel import gettext
from searx.result_types import EngineResults from searx.result_types import EngineResults
from searx.weather import DateTime, GeoLocation from searx.weather import DateTime, GeoLocation
@ -53,13 +52,13 @@ class SXNGPlugin(Plugin):
search_term = " ".join(query_parts).strip() search_term = " ".join(query_parts).strip()
if not search_term: 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())) results.add(results.types.Answer(answer=date_time.l10n()))
return results return results
geo = GeoLocation.by_query(search_term=search_term) geo = GeoLocation.by_query(search_term=search_term)
if geo: 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('_', ' ') tz_name = geo.timezone.replace('_', ' ')
results.add( results.add(
results.types.Answer( results.types.Answer(

View File

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

View File

@ -16,6 +16,7 @@ from typing import Iterable, List, Tuple, TYPE_CHECKING
from io import StringIO from io import StringIO
from codecs import getincrementalencoder from codecs import getincrementalencoder
import msgspec
from flask_babel import gettext, format_date # type: ignore from flask_babel import gettext, format_date # type: ignore
from searx import logger, get_setting 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 class JSONEncoder(json.JSONEncoder): # pylint: disable=missing-class-docstring
def default(self, o): def default(self, o):
if isinstance(o, msgspec.Struct):
return msgspec.to_builtins(o)
if isinstance(o, datetime): if isinstance(o, datetime):
return o.isoformat() return o.isoformat()
if isinstance(o, timedelta): if isinstance(o, timedelta):