diff --git a/client/simple/src/less/style.less b/client/simple/src/less/style.less index 4725f7db8..df7710b83 100644 --- a/client/simple/src/less/style.less +++ b/client/simple/src/less/style.less @@ -19,6 +19,7 @@ @import "new_issue.less"; @import "stats.less"; @import "result_templates.less"; +@import "weather.less"; // for index.html template @import "index.less"; diff --git a/client/simple/src/less/weather.less b/client/simple/src/less/weather.less new file mode 100644 index 000000000..b77747a96 --- /dev/null +++ b/client/simple/src/less/weather.less @@ -0,0 +1,38 @@ +#answers .weather { + summary { + display: block; + list-style: none; + } + + div.summary { + margin: 0; + padding: 0.5rem 1rem; + background-color: var(--color-header-background); + .rounded-corners-tiny; + } + + table { + font-size: 0.9rem; + table-layout: fixed; + margin-top: 0.5rem; + margin-bottom: 0.5rem; + } + + td { + padding: 0; + } + + img.symbol { + width: 5rem; + margin: auto; + display: block; + } + + .title { + // background-color: var(--color-result-keyvalue-even); + } + + .measured { + // background-color: var(--color-result-keyvalue-odd); + } +} diff --git a/docs/src/searx.weather.rst b/docs/src/searx.weather.rst new file mode 100644 index 000000000..6ee2a1848 --- /dev/null +++ b/docs/src/searx.weather.rst @@ -0,0 +1,8 @@ +.. _weather: + +======= +Weather +======= + +.. automodule:: searx.weather + :members: diff --git a/searx/babel_extract.py b/searx/babel_extract.py index f50756a48..65705efd6 100644 --- a/searx/babel_extract.py +++ b/searx/babel_extract.py @@ -45,6 +45,14 @@ def extract( namespace = {} exec(fileobj.read(), {}, namespace) # pylint: disable=exec-used - for name in namespace['__all__']: - for k, v in namespace[name].items(): - yield 0, '_', v, ["%s['%s']" % (name, k)] + for obj_name in namespace['__all__']: + obj = namespace[obj_name] + if isinstance(obj, list): + for msg in obj: + # (lineno, funcname, message, comments) + yield 0, '_', msg, [f"{obj_name}"] + elif isinstance(obj, dict): + for k, msg in obj.items(): + yield 0, '_', msg, [f"{obj_name}['{k}']"] + else: + raise ValueError(f"{obj_name} should be list or dict") diff --git a/searx/cache.py b/searx/cache.py index 96644419b..7ba5c8886 100644 --- a/searx/cache.py +++ b/searx/cache.py @@ -226,7 +226,7 @@ class ExpireCacheSQLite(sqlitedb.SQLiteAppl, ExpireCache): # The key/value tables will be created on demand by self.create_table DDL_CREATE_TABLES = {} - CACHE_TABLE_PREFIX = "CACHE-TABLE-" + CACHE_TABLE_PREFIX = "CACHE-TABLE" def __init__(self, cfg: ExpireCacheCfg): """An instance of the SQLite expire cache is build up from a diff --git a/searx/engines/open_meteo.py b/searx/engines/open_meteo.py index d6680f697..31ada12b0 100644 --- a/searx/engines/open_meteo.py +++ b/searx/engines/open_meteo.py @@ -1,18 +1,17 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Open Meteo (weather)""" -from urllib.parse import urlencode, quote_plus +from urllib.parse import urlencode from datetime import datetime -from flask_babel import gettext -from searx.network import get -from searx.exceptions import SearxEngineAPIException -from searx.result_types import EngineResults, Weather +from searx.result_types import EngineResults, WeatherAnswer +from searx import weather + about = { - "website": 'https://open-meteo.com', + "website": "https://open-meteo.com", "wikidata_id": None, - "official_api_documentation": 'https://open-meteo.com/en/docs', + "official_api_documentation": "https://open-meteo.com/en/docs", "use_official_api": True, "require_api_key": False, "results": "JSON", @@ -23,98 +22,129 @@ categories = ["weather"] geo_url = "https://geocoding-api.open-meteo.com" api_url = "https://api.open-meteo.com" -data_of_interest = "temperature_2m,relative_humidity_2m,apparent_temperature,cloud_cover,pressure_msl,wind_speed_10m,wind_direction_10m" # pylint: disable=line-too-long +data_of_interest = ( + "temperature_2m", + "apparent_temperature", + "relative_humidity_2m", + "apparent_temperature", + "cloud_cover", + "pressure_msl", + "wind_speed_10m", + "wind_direction_10m", + "weather_code", + # "visibility", + # "is_day", +) def request(query, params): - location_url = f"{geo_url}/v1/search?name={quote_plus(query)}" - resp = get(location_url) - if resp.status_code != 200: - raise SearxEngineAPIException("invalid geo location response code") + try: + location = weather.GeoLocation.by_query(query) + except ValueError: + return - json_locations = resp.json().get("results", []) - if len(json_locations) == 0: - raise SearxEngineAPIException("location not found") - - location = json_locations[0] args = { - 'latitude': location['latitude'], - 'longitude': location['longitude'], - 'timeformat': 'unixtime', - 'format': 'json', - 'current': data_of_interest, - 'forecast_days': 7, - 'hourly': data_of_interest, + "latitude": location.latitude, + "longitude": location.longitude, + "timeformat": "unixtime", + "timezone": "auto", # use timezone of the location + "format": "json", + "current": ",".join(data_of_interest), + "forecast_days": 3, + "hourly": ",".join(data_of_interest), } - params['url'] = f"{api_url}/v1/forecast?{urlencode(args)}" - params['location'] = location['name'] - - return params + params["url"] = f"{api_url}/v1/forecast?{urlencode(args)}" -def c_to_f(temperature): - return "%.2f" % ((temperature * 1.8) + 32) +# https://open-meteo.com/en/docs#weather_variable_documentation +# https://nrkno.github.io/yr-weather-symbols/ + +WMO_TO_CONDITION: dict[int, weather.WeatherConditionType] = { + # 0 Clear sky + 0: "clear sky", + # 1, 2, 3 Mainly clear, partly cloudy, and overcast + 1: "fair", + 2: "partly cloudy", + 3: "cloudy", + # 45, 48 Fog and depositing rime fog + 45: "fog", + 48: "fog", + # 51, 53, 55 Drizzle: Light, moderate, and dense intensity + 51: "light rain", + 53: "light rain", + 55: "light rain", + # 56, 57 Freezing Drizzle: Light and dense intensity + 56: "light sleet showers", + 57: "light sleet", + # 61, 63, 65 Rain: Slight, moderate and heavy intensity + 61: "light rain", + 63: "rain", + 65: "heavy rain", + # 66, 67 Freezing Rain: Light and heavy intensity + 66: "light sleet showers", + 67: "light sleet", + # 71, 73, 75 Snow fall: Slight, moderate, and heavy intensity + 71: "light sleet", + 73: "sleet", + 75: "heavy sleet", + # 77 Snow grains + 77: "snow", + # 80, 81, 82 Rain showers: Slight, moderate, and violent + 80: "light rain showers", + 81: "rain showers", + 82: "heavy rain showers", + # 85, 86 Snow showers slight and heavy + 85: "snow showers", + 86: "heavy snow showers", + # 95 Thunderstorm: Slight or moderate + 95: "rain and thunder", + # 96, 99 Thunderstorm with slight and heavy hail + 96: "light snow and thunder", + 99: "heavy snow and thunder", +} -def get_direction(degrees): - if degrees < 45 or degrees >= 315: - return "N" +def _weather_data(location: weather.GeoLocation, data: dict): - if 45 <= degrees < 135: - return "O" - - if 135 <= degrees < 225: - return "S" - - return "W" - - -def build_condition_string(data): - if data['relative_humidity_2m'] > 50: - return "rainy" - - if data['cloud_cover'] > 30: - return 'cloudy' - - return 'clear sky' - - -def generate_weather_data(data): - return Weather.DataItem( - condition=build_condition_string(data), - temperature=f"{data['temperature_2m']}°C / {c_to_f(data['temperature_2m'])}°F", - feelsLike=f"{data['apparent_temperature']}°C / {c_to_f(data['apparent_temperature'])}°F", - wind=( - f"{get_direction(data['wind_direction_10m'])}, " - f"{data['wind_direction_10m']}° — " - f"{data['wind_speed_10m']} km/h" - ), - pressure=f"{data['pressure_msl']}hPa", - humidity=f"{data['relative_humidity_2m']}hPa", - attributes={gettext('Cloud cover'): f"{data['cloud_cover']}%"}, + return WeatherAnswer.Item( + location=location, + temperature=weather.Temperature(unit="°C", value=data["temperature_2m"]), + condition=WMO_TO_CONDITION[data["weather_code"]], + feels_like=weather.Temperature(unit="°C", value=data["apparent_temperature"]), + 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"), + humidity=weather.RelativeHumidity(data["relative_humidity_2m"]), + cloud_cover=data["cloud_cover"], ) def response(resp): + location = weather.GeoLocation.by_query(resp.search_params["query"]) + res = EngineResults() json_data = resp.json() - current_weather = generate_weather_data(json_data['current']) - weather_answer = Weather( - location=resp.search_params['location'], - current=current_weather, + weather_answer = WeatherAnswer( + current=_weather_data(location, json_data["current"]), + service="Open-meteo", + # url="https://open-meteo.com/en/docs", ) - for index, time in enumerate(json_data['hourly']['time']): + for index, time in enumerate(json_data["hourly"]["time"]): + + if time < json_data["current"]["time"]: + # Cut off the hours that are already in the past + continue + hourly_data = {} + for key in data_of_interest: + hourly_data[key] = json_data["hourly"][key][index] - for key in data_of_interest.split(","): - hourly_data[key] = json_data['hourly'][key][index] - - forecast_data = generate_weather_data(hourly_data) - forecast_data.time = datetime.fromtimestamp(time).strftime('%Y-%m-%d %H:%M') + forecast_data = _weather_data(location, hourly_data) + forecast_data.datetime = weather.DateTime(datetime.fromtimestamp(time)) weather_answer.forecasts.append(forecast_data) res.add(weather_answer) diff --git a/searx/plugins/unit_converter.py b/searx/plugins/unit_converter.py index 0072afe55..8cefd1760 100644 --- a/searx/plugins/unit_converter.py +++ b/searx/plugins/unit_converter.py @@ -15,7 +15,7 @@ import babel.numbers from flask_babel import gettext, get_locale -from searx.units import symbol_to_si +from searx.wikidata_units import symbol_to_si from searx.plugins import Plugin, PluginInfo from searx.result_types import EngineResults diff --git a/searx/result_types/__init__.py b/searx/result_types/__init__.py index 8a82cf8d4..6d47d3a4f 100644 --- a/searx/result_types/__init__.py +++ b/searx/result_types/__init__.py @@ -13,14 +13,14 @@ from __future__ import annotations -__all__ = ["Result", "MainResult", "KeyValue", "EngineResults", "AnswerSet", "Answer", "Translations", "Weather"] +__all__ = ["Result", "MainResult", "KeyValue", "EngineResults", "AnswerSet", "Answer", "Translations", "WeatherAnswer"] import abc from searx import enginelib from ._base import Result, MainResult, LegacyResult -from .answer import AnswerSet, Answer, Translations, Weather +from .answer import AnswerSet, Answer, Translations, WeatherAnswer from .keyvalue import KeyValue @@ -35,7 +35,7 @@ class ResultList(list, abc.ABC): MainResult = MainResult Result = Result Translations = Translations - Weather = Weather + WeatherAnswer = WeatherAnswer # for backward compatibility LegacyResult = LegacyResult diff --git a/searx/result_types/answer.py b/searx/result_types/answer.py index d5793fac3..7ea0787a1 100644 --- a/searx/result_types/answer.py +++ b/searx/result_types/answer.py @@ -18,7 +18,7 @@ template. :members: :show-inheritance: -.. autoclass:: Weather +.. autoclass:: WeatherAnswer :members: :show-inheritance: @@ -30,10 +30,12 @@ template. from __future__ import annotations -__all__ = ["AnswerSet", "Answer", "Translations", "Weather"] +__all__ = ["AnswerSet", "Answer", "Translations", "WeatherAnswer"] +from flask_babel import gettext import msgspec +from searx import weather from ._base import Result @@ -149,49 +151,88 @@ class Translations(BaseAnswer, kw_only=True): """List of synonyms for the requested translation.""" -class Weather(BaseAnswer, kw_only=True): +class WeatherAnswer(BaseAnswer, kw_only=True): """Answer type for weather data.""" template: str = "answer/weather.html" """The template is located at :origin:`answer/weather.html `""" - location: str - """The geo-location the weather data is from (e.g. `Berlin, Germany`).""" - - current: Weather.DataItem + current: WeatherAnswer.Item """Current weather at ``location``.""" - forecasts: list[Weather.DataItem] = [] + forecasts: list[WeatherAnswer.Item] = [] """Weather forecasts for ``location``.""" - def __post_init__(self): - if not self.location: - raise ValueError("Weather answer is missing a location") + service: str = "" + """Weather service from which this information was provided.""" - class DataItem(msgspec.Struct, kw_only=True): - """A container for weather data such as temperature, humidity, ...""" + class Item(msgspec.Struct, kw_only=True): + """Weather parameters valid for a specific point in time.""" - time: str | None = None + location: weather.GeoLocation + """The geo-location the weather data is from (e.g. `Berlin, Germany`).""" + + temperature: weather.Temperature + """Air temperature at 2m above the ground.""" + + condition: weather.WeatherConditionType + """Standardized designations that summarize the weather situation + (e.g. ``light sleet showers and thunder``).""" + + # optional fields + + datetime: weather.DateTime | None = None """Time of the forecast - not needed for the current weather.""" - condition: str - """Weather condition, e.g. `cloudy`, `rainy`, `sunny` ...""" + summary: str | None = None + """One-liner about the weather forecast / current weather conditions. + If unset, a summary is build up from temperature and current weather + conditions. + """ - temperature: str - """Temperature string, e.g. `17°C`""" + feels_like: weather.Temperature | None = None + """Apparent temperature, the temperature equivalent perceived by + humans, caused by the combined effects of air temperature, relative + humidity and wind speed. The measure is most commonly applied to the + perceived outdoor temperature. + """ - feelsLike: str | None = None - """Felt temperature string, should be formatted like ``temperature``""" + pressure: weather.Pressure | None = None + """Air pressure at sea level (e.g. 1030 hPa) """ - humidity: str | None = None - """Humidity percentage string, e.g. `60%`""" + humidity: weather.RelativeHumidity | None = None + """Amount of relative humidity in the air at 2m above the ground. The + unit is ``%``, e.g. 60%) + """ - pressure: str | None = None - """Pressure string, e.g. `1030hPa`""" + wind_from: weather.Compass + """The directon which moves towards / direction the wind is coming from.""" - wind: str | None = None - """Information about the wind, e.g. `W, 231°, 10 m/s`""" + wind_speed: weather.WindSpeed | None = None + """Speed of wind / wind speed at 10m above the ground (10 min average).""" - attributes: dict[str] = [] - """Key-Value dict of additional weather attributes that are not available above""" + cloud_cover: int | None = None + """Amount of sky covered by clouds / total cloud cover for all heights + (cloudiness, unit: %)""" + + # attributes: dict[str, str | int] = {} + # """Key-Value dict of additional typeless weather attributes.""" + + def __post_init__(self): + if not self.summary: + self.summary = gettext("{location}: {temperature}, {condition}").format( + location=self.location, + temperature=self.temperature, + condition=gettext(self.condition.capitalize()), + ) + + @property + def url(self) -> str | None: + """Determines a `data URL`_ with a symbol for the weather + conditions. If no symbol can be assigned, ``None`` is returned. + + .. _data URL: + https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data + """ + return weather.symbol_url(self.condition) diff --git a/searx/searxng.msg b/searx/searxng.msg index a4bfb038a..7401b8313 100644 --- a/searx/searxng.msg +++ b/searx/searxng.msg @@ -3,8 +3,12 @@ """A SearXNG message file, see :py:obj:`searx.babel` """ +import typing + from searx import webutils from searx import engines +from searx.weather import WeatherConditionType + __all__ = [ 'CONSTANT_NAMES', @@ -13,6 +17,7 @@ __all__ = [ 'STYLE_NAMES', 'BRAND_CUSTOM_LINKS', 'WEATHER_TERMS', + 'WEATHER_CONDITIONS', 'SOCIAL_MEDIA_TERMS', ] @@ -85,6 +90,13 @@ WEATHER_TERMS = { 'WIND': 'Wind', } + +WEATHER_CONDITIONS = [ + # The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel") + msg.capitalize() + for msg in typing.get_args(WeatherConditionType) +] + SOCIAL_MEDIA_TERMS = { 'SUBSCRIBERS': 'subscribers', 'POSTS': 'posts', diff --git a/searx/templates/simple/answer/weather.html b/searx/templates/simple/answer/weather.html index 4cea9b683..bd59d4cfe 100644 --- a/searx/templates/simple/answer/weather.html +++ b/searx/templates/simple/answer/weather.html @@ -1,67 +1,62 @@ -{% macro show_weather_data(data) %} - - - {%- if data.condition -%} - - - - - {%- endif -%} - {%- if data.temperature -%} - - - - - {%- endif -%} - {%- if data.feelsLike -%} - - - - - {%- endif -%} - {%- if data.wind -%} - - - - - {%- endif -%} - {%- if data.humidity -%} - - - - - {%- endif -%} - {%- if data.pressure -%} - - - - - {%- endif -%} - - {%- for name, value in data.attributes.items() -%} - - - - - {%- endfor -%} - -
{{ _("Condition") }}{{ data.condition }}
{{ _("Temperature") }}{{ data.temperature }}
{{ _("Feels Like") }}{{ data.feelsLike }}
{{ _("Wind") }}{{ data.wind }}
{{ _("Humidity") }}{{ data.humidity }}
{{ _("Pressure") }}{{ data.pressure }}
{{ name }}{{ value }}
+{% macro show_weather_data(answer, data) %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {%- if data.url %}{% endif -%} +
{{ _("Temperature") }}:{{ data.temperature.l10n(locale=data.location) }}{{ _("Feels Like") }}:{{ data.feels_like.l10n(locale=data.location) }}
{{ _("Wind") }}:{{ data.wind_from.l10n(locale=data.location) }}: {{ data.wind_speed.l10n(locale=data.location) }}{{ _("Pressure") }}:{{ data.pressure.l10n(locale=data.location) }}
{{_("Humidity")}}:{{ data.humidity.l10n(locale=data.location) }}
{% endmacro %} -
- It's currently {{ answer.current.condition }}, {{ answer.current.temperature }} in {{ answer.location }} -
-

{{ answer.location }}

-

{{ _("Current condition") }}

- {{ show_weather_data(answer.current) }} - +
+ +
{{ answer.current.summary }}
+ {{ show_weather_data(answer, answer.current) }} +
+
{%- if answer.forecasts -%} -
- {%- for forecast in answer.forecasts -%} -

{{ forecast.time }}

- {{ show_weather_data(forecast) }} - {%- endfor -%} -
+
+ {%- for forecast in answer.forecasts -%} +
{{ forecast.datetime.l10n(locale=answer.current.location,fmt="short") }} {{ forecast.summary }}
+ {{ show_weather_data(answer, forecast) }} + {%- endfor -%} +
{%- endif -%}
+ +{%- if answer.url -%} + + {{ answer.service }} + +{%- else -%} + {{ answer.service }} +{% endif -%} diff --git a/searx/weather.py b/searx/weather.py new file mode 100644 index 000000000..fb62515b6 --- /dev/null +++ b/searx/weather.py @@ -0,0 +1,605 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Implementations used for weather conditions and forecast.""" +# pylint: disable=too-few-public-methods +from __future__ import annotations + +__all__ = [ + "symbol_url", + "Temperature", + "Pressure", + "WindSpeed", + "RelativeHumidity", + "Compass", + "WeatherConditionType", + "DateTime", + "GeoLocation", +] + +import typing + +import base64 +import datetime +import dataclasses + +from urllib.parse import quote_plus + +import babel +import babel.numbers +import babel.dates +import babel.languages + +from searx import network +from searx.cache import ExpireCache, ExpireCacheCfg +from searx.extended_types import sxng_request +from searx.wikidata_units import convert_to_si, convert_from_si + +WEATHER_DATA_CACHE: ExpireCache = None # type: ignore +"""A simple cache for weather data (geo-locations, icons, ..)""" + +YR_WEATHER_SYMBOL_URL = "https://raw.githubusercontent.com/nrkno/yr-weather-symbols/refs/heads/master/symbols/outline" + + +def get_WEATHER_DATA_CACHE(): + + global WEATHER_DATA_CACHE # pylint: disable=global-statement + + if WEATHER_DATA_CACHE is None: + WEATHER_DATA_CACHE = ExpireCache.build_cache( + ExpireCacheCfg( + name="WEATHER_DATA_CACHE", + MAX_VALUE_LEN=1024 * 200, # max. 200kB per icon (icons have most often 10-20kB) + MAXHOLD_TIME=60 * 60 * 24 * 7 * 4, # 4 weeks + ) + ) + return WEATHER_DATA_CACHE + + +def _get_sxng_locale_tag() -> str: + # The function should return a locale (the sxng-tag: de-DE.en-US, ..) that + # can later be used to format and convert measured values for the output of + # weather data to the user. + # + # In principle, SearXNG only has two possible parameters for determining + # the locale: the UI language or the search- language/region. Since the + # conversion of weather data and time information is usually + # region-specific, the UI language is not suitable. + # + # It would probably be ideal to use the user's geolocation, but this will + # probably never be available in SearXNG (privacy critical). + # + # Therefore, as long as no "better" parameters are available, this function + # returns a locale based on the search region. + + # pylint: disable=import-outside-toplevel,disable=cyclic-import + from searx import query + from searx.preferences import ClientPref + + query = query.RawTextQuery(sxng_request.form.get("q", ""), []) + if query.languages and query.languages[0] not in ["all", "auto"]: + return query.languages[0] + + search_lang = sxng_request.form.get("language") + if search_lang and search_lang not in ["all", "auto"]: + return search_lang + + client_pref = ClientPref.from_http_request(sxng_request) + search_lang = client_pref.locale_tag + if search_lang and search_lang not in ["all", "auto"]: + return search_lang + return "en" + + +def symbol_url(condition: WeatherConditionType) -> str | None: + """Returns ``data:`` URL for the weather condition symbol or ``None`` if + the condition is not of type :py:obj:`WeatherConditionType`. + + If symbol (SVG) is not already in the :py:obj:`WEATHER_DATA_CACHE` its + fetched from https://github.com/nrkno/yr-weather-symbols + """ + # Symbols for darkmode/lightmode? .. and day/night symbols? .. for the + # latter we need a geopoint (critical in sense of privacy) + + fname = YR_WEATHER_SYMBOL_MAP.get(condition) + if fname is None: + return None + + ctx = "weather_symbol_url" + cache = get_WEATHER_DATA_CACHE() + origin_url = f"{YR_WEATHER_SYMBOL_URL}/{fname}.svg" + + data_url = cache.get(origin_url, ctx=ctx) + if data_url is not None: + return data_url + + response = network.get(origin_url, timeout=3) + if response.status_code == 200: + mimetype = response.headers['Content-Type'] + data_url = f"data:{mimetype};base64,{str(base64.b64encode(response.content), 'utf-8')}" + cache.set(key=origin_url, value=data_url, expire=None, ctx=ctx) + return data_url + + +@dataclasses.dataclass +class GeoLocation: + """Minimal implementation of Geocoding.""" + + # The type definition was based on the properties from the geocoding API of + # open-meteo. + # + # - https://open-meteo.com/en/docs/geocoding-api + # - https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + # - https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 + + name: str + latitude: float # Geographical WGS84 coordinates of this location + longitude: float + elevation: float # Elevation above mean sea level of this location + country_code: str # 2-Character ISO-3166-1 alpha2 country code. E.g. DE for Germany + timezone: str # Time zone using time zone database definitions + + def __str__(self): + return self.name + + def locale(self) -> babel.Locale: + + # by region of the search language + sxng_tag = _get_sxng_locale_tag() + if "-" in sxng_tag: + locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-') + return locale + + # by most popular language in the region (country code) + for lang in babel.languages.get_official_languages(self.country_code): + try: + locale = babel.Locale.parse(f"{lang}_{self.country_code}") + return locale + except babel.UnknownLocaleError: + continue + + # No locale could be determined. This does not actually occur, but if + # it does, the English language is used by default. But not region US. + # US has some units that are only used in US but not in the rest of the + # world (e.g. °F instead of °C) + return babel.Locale("en", territory="DE") + + @classmethod + def by_query(cls, search_term: str) -> GeoLocation: + """Factory method to get a GeoLocation object by a search term. If no + location can be determined for the search term, a :py:obj:`ValueError` + is thrown. + """ + + ctx = "weather_geolocation_by_query" + cache = get_WEATHER_DATA_CACHE() + geo_props = cache.get(search_term, ctx=ctx) + + if not geo_props: + geo_props = cls._query_open_meteo(search_term=search_term) + cache.set(key=search_term, value=geo_props, expire=None, ctx=ctx) + + return cls(**geo_props) + + @classmethod + def _query_open_meteo(cls, search_term: str) -> dict: + url = f"https://geocoding-api.open-meteo.com/v1/search?name={quote_plus(search_term)}" + resp = network.get(url, timeout=3) + if resp.status_code != 200: + raise ValueError(f"unknown geo location: '{search_term}'") + results = resp.json().get("results") + 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)} + + +DateTimeFormats = typing.Literal["full", "long", "medium", "short"] + + +class DateTime: + """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 + provided (in the current version). + """ + + def __init__(self, time: datetime.datetime): + self.datetime = time + + def __str__(self): + return self.l10n() + + def l10n( + self, + fmt: DateTimeFormats | str = "medium", + locale: babel.Locale | GeoLocation | None = None, + ) -> str: + """Localized representation of date & time.""" + if isinstance(locale, GeoLocation): + locale = locale.locale() + elif locale is None: + locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-') + return babel.dates.format_datetime(self.datetime, format=fmt, locale=locale) + + +class Temperature: + """Class for converting temperature units and for string representation of + measured values.""" + + si_name = "Q11579" + + Units = typing.Literal["°C", "°F", "K"] + """Supported temperature units.""" + + 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 __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 l10n( + self, + unit: Units | None = None, + locale: babel.Locale | GeoLocation | None = None, + template: str = "{value} {unit}", + num_pattern: str = "#,##0", + ) -> str: + """Localized representation of a measured value. + + If the ``unit`` is not set, an attempt is made to determine a ``unit`` + matching the territory of the ``locale``. If the locale is not set, an + attempt is made to determine it from the HTTP request. + + The value is converted into the respective unit before formatting. + + The argument ``num_pattern`` is used to determine the string formatting + of the numerical value: + + - https://babel.pocoo.org/en/latest/numbers.html#pattern-syntax + - https://unicode.org/reports/tr35/tr35-numbers.html#Number_Format_Patterns + + The argument ``template`` specifies how the **string formatted** value + and unit are to be arranged. + + - `Format Specification Mini-Language + `. + """ + + if isinstance(locale, GeoLocation): + locale = locale.locale() + elif locale is None: + locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-') + + if unit is None: # unit by territory + unit = "°C" + if locale.territory in ["US"]: + unit = "°F" + val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern) + return template.format(value=val_str, unit=unit) + + +class Pressure: + """Class for converting pressure units and for string representation of + measured values.""" + + si_name = "Q44395" + + Units = typing.Literal["Pa", "hPa", "cm Hg", "bar"] + """Supported units.""" + + 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 __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 l10n( + self, + unit: Units | None = None, + locale: babel.Locale | GeoLocation | None = None, + template: str = "{value} {unit}", + num_pattern: str = "#,##0", + ) -> str: + if isinstance(locale, GeoLocation): + locale = locale.locale() + elif locale is None: + locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-') + + if unit is None: # unit by territory? + unit = "hPa" + + val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern) + return template.format(value=val_str, unit=unit) + + +class WindSpeed: + """Class for converting speed or velocity units and for string + representation of measured values. + + .. hint:: + + Working with unit ``Bft`` (:py:obj:`searx.wikidata_units.Beaufort`) will + throw a :py:obj:`ValueError` for egative values or values greater 16 Bft + (55.6 m/s) + """ + + si_name = "Q182429" + + Units = typing.Literal["m/s", "km/h", "kn", "mph", "mi/h", "Bft"] + """Supported units.""" + + 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 __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 l10n( + self, + unit: Units | None = None, + locale: babel.Locale | GeoLocation | None = None, + template: str = "{value} {unit}", + num_pattern: str = "#,##0", + ) -> str: + if isinstance(locale, GeoLocation): + locale = locale.locale() + elif locale is None: + locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-') + + if unit is None: # unit by territory? + unit = "m/s" + + val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern) + return template.format(value=val_str, unit=unit) + + +class RelativeHumidity: + """Amount of relative humidity in the air. The unit is ``%``""" + + Units = typing.Literal["%"] + """Supported unit.""" + + units = list(typing.get_args(Units)) + + def __init__(self, humidity: float): + self.humidity = humidity + + def __str__(self): + return self.l10n() + + def value(self) -> float: + return self.humidity + + def l10n( + self, + locale: babel.Locale | GeoLocation | None = None, + template: str = "{value}{unit}", + num_pattern: str = "#,##0", + ) -> str: + if isinstance(locale, GeoLocation): + locale = locale.locale() + elif locale is None: + locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-') + + unit = "%" + val_str = babel.numbers.format_decimal(self.value(), locale=locale, format=num_pattern) + return template.format(value=val_str, unit=unit) + + +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" + ] + """Compass point type definition""" + + TURN = 360.0 + """Full turn (360°)""" + + POINTS = list(typing.get_args(Point)) + """Compass points.""" + + RANGE = 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 __str__(self): + return self.l10n() + + def value(self, unit: Units): + if unit == "Point": + return self.point(self.azimuth) + if unit == "°": + return self.azimuth + raise ValueError(f"unknown unit: {unit}") + + @classmethod + def point(cls, azimuth: float | int) -> Point: + """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 + # and after compass point (example: "N" goes from -11.25° to +11.25°) + azimuth = azimuth - cls.RANGE / 2 + idx = int(azimuth // cls.RANGE) + return cls.POINTS[idx] + + def l10n( + self, + unit: Units = "Point", + locale: babel.Locale | GeoLocation | None = None, + template: str = "{value}{unit}", + num_pattern: str = "#,##0", + ) -> str: + if isinstance(locale, GeoLocation): + locale = locale.locale() + elif locale is None: + locale = babel.Locale.parse(_get_sxng_locale_tag(), sep='-') + + if unit == "Point": + val_str = self.value(unit) + return template.format(value=val_str, unit="") + + val_str = babel.numbers.format_decimal(self.value(unit), locale=locale, format=num_pattern) + return template.format(value=val_str, unit=unit) + + +WeatherConditionType = typing.Literal[ + # The capitalized string goes into to i18n/l10n (en: "Clear sky" -> de: "wolkenloser Himmel") + "clear sky", + "cloudy", + "fair", + "fog", + "heavy rain and thunder", + "heavy rain showers and thunder", + "heavy rain showers", + "heavy rain", + "heavy sleet and thunder", + "heavy sleet showers and thunder", + "heavy sleet showers", + "heavy sleet", + "heavy snow and thunder", + "heavy snow showers and thunder", + "heavy snow showers", + "heavy snow", + "light rain and thunder", + "light rain showers and thunder", + "light rain showers", + "light rain", + "light sleet and thunder", + "light sleet showers and thunder", + "light sleet showers", + "light sleet", + "light snow and thunder", + "light snow showers and thunder", + "light snow showers", + "light snow", + "partly cloudy", + "rain and thunder", + "rain showers and thunder", + "rain showers", + "rain", + "sleet and thunder", + "sleet showers and thunder", + "sleet showers", + "sleet", + "snow and thunder", + "snow showers and thunder", + "snow showers", + "snow", +] +"""Standardized designations for weather conditions. The designators were +taken from a collaboration between NRK and Norwegian Meteorological Institute +(yr.no_). `Weather symbols`_ can be assigned to the identifiers +(weathericons_) and they are included in the translation (i18n/l10n +:origin:`searx/searxng.msg`). + +.. _yr.no: https://www.yr.no/en +.. _Weather symbols: https://github.com/nrkno/yr-weather-symbols +.. _weathericons: https://github.com/metno/weathericons +""" + +YR_WEATHER_SYMBOL_MAP = { + "clear sky": "01d", # 01d clearsky_day + "fair": "02d", # 02d fair_day + "partly cloudy": "03d", # 03d partlycloudy_day + "cloudy": "04", # 04 cloudy + "light rain showers": "40d", # 40d lightrainshowers_day + "rain showers": "05d", # 05d rainshowers_day + "heavy rain showers": "41d", # 41d heavyrainshowers_day + "light rain showers and thunder": "24d", # 24d lightrainshowersandthunder_day + "rain showers and thunder": "06d", # 06d rainshowersandthunder_day + "heavy rain showers and thunder": "25d", # 25d heavyrainshowersandthunder_day + "light sleet showers": "42d", # 42d lightsleetshowers_day + "sleet showers": "07d", # 07d sleetshowers_day + "heavy sleet showers": "43d", # 43d heavysleetshowers_day + "light sleet showers and thunder": "26d", # 26d lightssleetshowersandthunder_day + "sleet showers and thunder": "20d", # 20d sleetshowersandthunder_day + "heavy sleet showers and thunder": "27d", # 27d heavysleetshowersandthunder_day + "light snow showers": "44d", # 44d lightsnowshowers_day + "snow showers": "08d", # 08d snowshowers_day + "heavy snow showers": "45d", # 45d heavysnowshowers_day + "light snow showers and thunder": "28d", # 28d lightssnowshowersandthunder_day + "snow showers and thunder": "21d", # 21d snowshowersandthunder_day + "heavy snow showers and thunder": "29d", # 29d heavysnowshowersandthunder_day + "light rain": "46", # 46 lightrain + "rain": "09", # 09 rain + "heavy rain": "10", # 10 heavyrain + "light rain and thunder": "30", # 30 lightrainandthunder + "rain and thunder": "22", # 22 rainandthunder + "heavy rain and thunder": "11", # 11 heavyrainandthunder + "light sleet": "47", # 47 lightsleet + "sleet": "12", # 12 sleet + "heavy sleet": "48", # 48 heavysleet + "light sleet and thunder": "31", # 31 lightsleetandthunder + "sleet and thunder": "23", # 23 sleetandthunder + "heavy sleet and thunder": "32", # 32 heavysleetandthunder + "light snow": "49", # 49 lightsnow + "snow": "13", # 13 snow + "heavy snow": "50", # 50 heavysnow + "light snow and thunder": "33", # 33 lightsnowandthunder + "snow and thunder": "14", # 14 snowandthunder + "heavy snow and thunder": "34", # 34 heavysnowandthunder + "fog": "15", # 15 fog +} +"""Map a :py:obj:`WeatherConditionType` to a `YR weather symbol`_ + +.. code:: + + base_url = "https://raw.githubusercontent.com/nrkno/yr-weather-symbols/refs/heads/master/symbols" + icon_url = f"{base_url}/outline/{YR_WEATHER_SYMBOL_MAP['sleet showers']}.svg" + +.. _YR weather symbol: https://github.com/nrkno/yr-weather-symbols/blob/master/locales/en.json + +""" + +if __name__ == "__main__": + + # test: fetch all symbols of the type catalog .. + for c in typing.get_args(WeatherConditionType): + symbol_url(condition=c) + + _cache = get_WEATHER_DATA_CACHE() + title = "cached weather condition symbols" + print(title) + print("=" * len(title)) + print(_cache.state().report()) + print() + title = f"properties of {_cache.cfg.name}" + print(title) + print("=" * len(title)) + print(str(_cache.properties)) # type: ignore diff --git a/searx/wikidata_units.py b/searx/wikidata_units.py index 9fc94585f..b05ded220 100644 --- a/searx/wikidata_units.py +++ b/searx/wikidata_units.py @@ -5,6 +5,7 @@ Coordinates`_ .. _SPARQL/WIKIDATA Precision, Units and Coordinates: https://en.wikibooks.org/wiki/SPARQL/WIKIDATA_Precision,_Units_and_Coordinates#Quantities """ +from __future__ import annotations __all__ = ["convert_from_si", "convert_to_si", "symbol_to_si"] @@ -13,6 +14,47 @@ import collections from searx import data from searx.engines import wikidata + +class Beaufort: + """The mapping of the Beaufort_ contains values from 0 to 16 (55.6 m/s), + wind speeds greater than 200km/h (55.6 m/s) are given as 17 Bft. Thats why + a value of 17 Bft cannot be converted to SI. + + .. hint:: + + Negative values or values greater 16 Bft (55.6 m/s) will throw a + :py:obj:`ValueError`. + + _Beaufort: https://en.wikipedia.org/wiki/Beaufort_scale + """ + + # fmt: off + scale: list[float] = [ + 0.2, 1.5, 3.3, 5.4, 7.9, + 10.7, 13.8, 17.1, 20.7, 24.4, + 28.4, 32.6, 32.7, 41.1, 45.8, + 50.8, 55.6 + ] + # fmt: on + + @classmethod + def from_si(cls, value) -> float: + if value < 0 or value > 55.6: + raise ValueError(f"invalid value {value} / the Beaufort scales from 0 to 16 (55.6 m/s)") + bft = 0 + for bft, mps in enumerate(cls.scale): + if mps >= value: + break + return bft + + @classmethod + def to_si(cls, value) -> float: + idx = round(value) + if idx < 0 or idx > 16: + raise ValueError(f"invalid value {value} / the Beaufort scales from 0 to 16 (55.6 m/s)") + return cls.scale[idx] + + ADDITIONAL_UNITS = [ { "si_name": "Q11579", @@ -26,6 +68,12 @@ ADDITIONAL_UNITS = [ "to_si": lambda val: (val + 459.67) * 5 / 9, "from_si": lambda val: (val * 9 / 5) - 459.67, }, + { + "si_name": "Q182429", + "symbol": "Bft", + "to_si": Beaufort.to_si, + "from_si": Beaufort.from_si, + }, ] """Additional items to convert from a measure unit to a SI unit (vice versa). @@ -55,6 +103,7 @@ ALIAS_SYMBOLS = { '°C': ('C',), '°F': ('F',), 'mi': ('L',), + 'Bft': ('bft',), } """Alias symbols for known unit of measure symbols / by example:: @@ -65,11 +114,11 @@ ALIAS_SYMBOLS = { SYMBOL_TO_SI = [] -UNITS_BY_SI_NAME: dict | None = None +UNITS_BY_SI_NAME: dict = {} def convert_from_si(si_name: str, symbol: str, value: float | int) -> float: - from_si = units_by_si_name(si_name)[symbol][symbol]["from_si"] + from_si = units_by_si_name(si_name)[symbol][pos_from_si] if isinstance(from_si, (float, int)): value = float(value) * from_si else: @@ -78,7 +127,7 @@ def convert_from_si(si_name: str, symbol: str, value: float | int) -> float: def convert_to_si(si_name: str, symbol: str, value: float | int) -> float: - to_si = units_by_si_name(si_name)[symbol][symbol]["to_si"] + to_si = units_by_si_name(si_name)[symbol][pos_to_si] if isinstance(to_si, (float, int)): value = float(value) * to_si else: @@ -88,20 +137,32 @@ def convert_to_si(si_name: str, symbol: str, value: float | int) -> float: def units_by_si_name(si_name): - global UNITS_BY_SI_NAME - if UNITS_BY_SI_NAME is not None: + global UNITS_BY_SI_NAME # pylint: disable=global-statement,global-variable-not-assigned + if UNITS_BY_SI_NAME: return UNITS_BY_SI_NAME[si_name] - UNITS_BY_SI_NAME = {} + # build the catalog .. for item in symbol_to_si(): - by_symbol = UNITS_BY_SI_NAME.get(si_name) + + item_si_name = item[pos_si_name] + item_symbol = item[pos_symbol] + + by_symbol = UNITS_BY_SI_NAME.get(item_si_name) if by_symbol is None: by_symbol = {} - UNITS_BY_SI_NAME[si_name] = by_symbol - by_symbol[item["symbol"]] = item + UNITS_BY_SI_NAME[item_si_name] = by_symbol + by_symbol[item_symbol] = item + return UNITS_BY_SI_NAME[si_name] +pos_symbol = 0 # (alias) symbol +pos_si_name = 1 # si_name +pos_from_si = 2 # from_si +pos_to_si = 3 # to_si +pos_symbol = 4 # standardized symbol + + def symbol_to_si(): """Generates a list of tuples, each tuple is a measure unit and the fields in the tuple are: