From 213a6c2f29551d8ab595e691cbc6424633ea95af Mon Sep 17 00:00:00 2001 From: txoof-bot <337660340+txoof-bot@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:41:07 +0200 Subject: [PATCH] Place search for met_no and moon_phase (M5 part 3c, 2 of 2) paperpi.places finds places by name with Open-Meteo (browser language, then English) and offers a ready-made location helper. met_no gets an optional altitude setting, sent to met.no. met_no and moon_phase fill lat and lon from the search on their settings page. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/setting-helpers.md | 1 + docs/writing-plugins.md | 2 + paperpi.example.toml | 6 + src/paperpi/limits.py | 8 + src/paperpi/places.py | 151 +++++++++++++++++ src/paperpi/plugins/met_no/README.md | 7 +- src/paperpi/plugins/met_no/__init__.py | 24 ++- src/paperpi/plugins/moon_phase/README.md | 2 + src/paperpi/plugins/moon_phase/__init__.py | 9 +- tests/test_met_no.py | 23 ++- tests/test_moon_phase.py | 16 +- tests/test_places.py | 187 +++++++++++++++++++++ tests/test_web_forms.py | 2 +- 13 files changed, 429 insertions(+), 9 deletions(-) create mode 100644 src/paperpi/places.py create mode 100644 tests/test_places.py diff --git a/docs/decisions/setting-helpers.md b/docs/decisions/setting-helpers.md index 9c9bac8..ae7adfb 100644 --- a/docs/decisions/setting-helpers.md +++ b/docs/decisions/setting-helpers.md @@ -108,6 +108,7 @@ A choice is a dictionary. Each key is a **kind** that says what PaperPi does wit ### The first helper: `location` in `met_no` and `moon_phase` - The place search uses **Open-Meteo** (`geocoding-api.open-meteo.com`), chosen with txoof on 2026-10-10. No key and no sign-up. It finds towns and cities (not street addresses) and returns region, country, latitude, longitude **and altitude**; Nominatim, which v1 used, returns no altitude. It understands "Morrison, Colorado"; a plain "Morrison" returns 10 towns in several countries, so region and country are shown with each choice. +- It searches in the language the browser asks for first and then in English, and lists each place once (by Open-Meteo's number for it), with its name in the browser's language (or in English, if it was found only in English). If one of the two searches fails, the other one's places are shown. Both requests must fit in the 30-second time limit of a search, so each may take at most 12 seconds with its retry, and 5 seconds to connect. - Terms: free for non-commercial use, fewer than 10,000 requests a day. The data is CC BY 4.0 and the place data is from GeoNames, so the page shows "Search by Open-Meteo, place data from GeoNames" with links next to the list. - A choice: - `fill`: `lat` and `lon` (4 decimals, about 10 m), `altitude` (whole metres, as met.no asks), and the name shown on the screen (`place` in `met_no`). The name is always replaced: choosing a place is the point of the search, and the user can change the name afterwards (txoof, 2026-10-10, after trying the demo; the first plan filled it only while empty, so it was set once and never again); diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 260011a..52e608f 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -140,6 +140,8 @@ def find_stops(query: Query): # helpers={"stops": Helper(find_stops, prompt="Name of the stop, e.g. Centraal Station")}, ``` +For a latitude and longitude, use PaperPi's ready-made helper instead of writing one: `paperpi.places.location_helper()` searches for places by name (Open-Meteo, in the user's language and in English) and fills in `lat` and `lon`. Pass the names of your settings if they are called something else. If your plugin also has settings for the height (in whole metres) or for the name shown on the screen, pass their names too, e.g. `altitude="height", name="place"` (the name is always replaced). `met_no` uses `location_helper(altitude="altitude", name="place")`, `moon_phase` plain `location_helper()`. + The `debugging` plugin has a helper to try it out: `words` turns each typed word into a choice for its `text` setting. ## Layouts diff --git a/paperpi.example.toml b/paperpi.example.toml index d12babe..b51408b 100644 --- a/paperpi.example.toml +++ b/paperpi.example.toml @@ -108,6 +108,9 @@ lat = 52.52 lon = 13.4 # Name shown on the screen, e.g. Berlin (else lat, lon) place = "Berlin" +# Height of the ground at the place, in whole metres, e.g. 34. Makes the temperatures more exact in +# hills and mountains (else met.no guesses it from its own map) +# altitude = # Your own, real email address, sent only to met.no (their terms of service ask for one) (required) # email = "" # Degrees Celsius or Fahrenheit. One of: "C", "F" @@ -132,6 +135,9 @@ lat = -22.91 lon = -43.17 # Name shown on the screen, e.g. Berlin (else lat, lon) place = "Rio" +# Height of the ground at the place, in whole metres, e.g. 34. Makes the temperatures more exact in +# hills and mountains (else met.no guesses it from its own map) +# altitude = # Your own, real email address, sent only to met.no (their terms of service ask for one) (required) # email = "" # Degrees Celsius or Fahrenheit. One of: "C", "F" diff --git a/src/paperpi/limits.py b/src/paperpi/limits.py index 5f13b3c..5571019 100644 --- a/src/paperpi/limits.py +++ b/src/paperpi/limits.py @@ -27,6 +27,14 @@ #: (``docs/decisions/setting-helpers.md``). HELPER_SEARCH = 30.0 +#: Longest one request to the place search may take (``paperpi.places``), with its retry. +#: A search may need two requests (the browser's language, then English), and both must fit +#: in :data:`HELPER_SEARCH`, with time left to start the plugin process. +PLACE_REQUEST = 12.0 + +#: Longest the place search may take to connect. +PLACE_CONNECT = 5.0 + #: Most characters the user may type into a helper's search box. HELPER_TEXT = 100 diff --git a/src/paperpi/places.py b/src/paperpi/places.py new file mode 100644 index 0000000..2eb7501 --- /dev/null +++ b/src/paperpi/places.py @@ -0,0 +1,151 @@ +"""Find places by name, for plugins that need a latitude and longitude (such as the weather). + +The search is Open-Meteo's place search (chosen with txoof on 2026-10-10, see +``docs/decisions/setting-helpers.md``): no key, and it gives each place's altitude too. Its +place data comes from GeoNames; both ask for credit (CC BY 4.0), which +:func:`location_helper` shows under the choices. + +:func:`location_helper` is a ready-made setting helper: the plugin names its settings for +latitude, longitude and, if it has them, altitude and the name shown on the screen. +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from urllib.parse import urlencode + +from . import limits, webrequest +from .helper import Helper, HelperProblem, Query + +log = logging.getLogger(__name__) + +URL = "https://geocoding-api.open-meteo.com/v1/search" +SOURCE = "Search by Open-Meteo, place data from GeoNames" +SOURCE_LINK = "https://open-meteo.com/en/docs/geocoding-api" + + +@dataclass(frozen=True) +class Place: + id: int + """Open-Meteo's number for the place (the same in every language).""" + name: str + region: str + """The state or province, e.g. Colorado (may be empty).""" + country: str + lat: float + lon: float + altitude: int | None + """Metres above sea level (``None``: not known).""" + + +def find_places(text: str, language: str = "en", count: int = 10) -> list[Place]: + """The places whose name matches ``text`` (e.g. "Morrison, Colorado"), best first, with + their names in ``language`` (or in English, for places found only in English). + + Open-Meteo finds a name only in the language it is asked in ("Den Haag" in Dutch, "The + Hague" in English), so a ``language`` other than English is searched too, first; a + place found in both is listed once. When one of the two searches fails, the other one's + places are listed. Raises :class:`~paperpi.webrequest.WebError` when every search + failed (Open-Meteo can't be reached).""" + places: dict[int, Place] = {} + failed: list[webrequest.WebError] = [] + languages = list(dict.fromkeys([language, "en"])) + for spoken in languages: + try: + found = _search(text, spoken, count) + except webrequest.WebError as error: + failed.append(error) + continue + for place in found: + places.setdefault(place.id, place) + if len(failed) == len(languages): + raise failed[-1] + return list(places.values())[:count] + + +def _search(text: str, language: str, count: int) -> list[Place]: + query = urlencode({"name": text, "count": count, "language": language, "format": "json"}) + found = webrequest.get( + f"{URL}?{query}", total=limits.PLACE_REQUEST, connect_timeout=limits.PLACE_CONNECT + ).json() + results = found.get("results") if isinstance(found, dict) else None + places = [] + for item in results if isinstance(results, list) else []: + try: + places.append(_place(item)) + except (KeyError, TypeError, ValueError) as error: + log.info("left out a place Open-Meteo sent that can't be read: %r (%s)", item, error) + return places + + +def _place(item: dict) -> Place: + """One place of Open-Meteo's answer. Raises KeyError, TypeError or ValueError when it + can't be used, so one odd place doesn't stop the others from being listed.""" + lat, lon = _number(item["latitude"]), _number(item["longitude"]) + if not (-90 <= lat <= 90 and -180 <= lon <= 180): + raise ValueError("latitude or longitude out of range") + try: + altitude = round(_number(item.get("elevation"))) + except (TypeError, ValueError): + altitude = None + return Place( + id=int(item["id"]), + name=str(item.get("name") or ""), + region=str(item.get("admin1") or ""), + country=str(item.get("country") or ""), + lat=round(lat, 4), + lon=round(lon, 4), + altitude=altitude, + ) + + +def _number(value: object) -> float: + """A finite number from the answer (not true or false, which Python counts as numbers).""" + if isinstance(value, bool) or not isinstance(value, int | float): + raise TypeError(f"not a number: {value!r}") + if not math.isfinite(value): + raise ValueError(f"not a finite number: {value!r}") + return float(value) + + +def location_helper( + lat: str = "lat", lon: str = "lon", altitude: str | None = None, name: str | None = None +) -> Helper: + """A setting helper that fills in latitude and longitude from a place name. ``lat`` and + ``lon`` name the plugin's settings for them; ``altitude`` (metres; emptied when the place's + altitude isn't known) and ``name`` (shown on the screen; always replaced, as choosing the + place is the point) are optional.""" + + def find(query: Query): + try: + places = find_places(query.text, query.language) + except webrequest.WebError as error: + raise HelperProblem(f"Open-Meteo could not be reached: {error}") from None + choices = [] + for place in places: + fill = {lat: place.lat, lon: place.lon} + if altitude: + # Empty when not known, so the altitude of an earlier choice is cleared. + fill[altitude] = "" if place.altitude is None else place.altitude + if name: + fill[name] = place.name + show = {"Place": place.name, "Region": place.region, "Country": place.country} + show = {k: v for k, v in show.items() if v} + if place.altitude is not None: + show["Altitude"] = f"{place.altitude} m" + show["Lat, lon"] = f"{place.lat}, {place.lon}" + map_link = ( + "https://www.openstreetmap.org/" + f"?mlat={place.lat}&mlon={place.lon}#map=12/{place.lat}/{place.lon}" + ) + choices.append({"fill": fill, "show": show, "link": {"See on map": map_link}}) + return choices + + return Helper( + find, + prompt="Find the place by name, e.g. Addis Ababa or Morrison, Colorado", + source=SOURCE, + source_link=SOURCE_LINK, + ) diff --git a/src/paperpi/plugins/met_no/README.md b/src/paperpi/plugins/met_no/README.md index 228be11..74ce037 100644 --- a/src/paperpi/plugins/met_no/README.md +++ b/src/paperpi/plugins/met_no/README.md @@ -27,9 +27,9 @@ Feathers add up: a long and a short feather is 15 knots; a triangle and two long ## When met.no can't be reached -"Updated 08:32" at the top is when met.no last confirmed the forecast. After every download the plugin saves the trimmed forecast in its storage folder. If met.no can't be reached, it draws from the saved forecast (still starting at the current hour), and the "Updated" time shows how old it is. A saved forecast older than 6 hours is not used; the update then fails as usual. After a change of `lat` or `lon` the saved forecast is not used: it is for the old place. +"Updated 08:32" at the top is when met.no last confirmed the forecast. After every download the plugin saves the trimmed forecast in its storage folder. If met.no can't be reached, it draws from the saved forecast (still starting at the current hour), and the "Updated" time shows how old it is. A saved forecast older than 6 hours is not used; the update then fails as usual. After a change of `lat`, `lon` or `altitude` the saved forecast is not used: it is for the old place. -The plugin follows met.no's [terms of service](https://api.met.no/doc/TermsOfService): it sends your email address (only to met.no) as contact, rounds the coordinates to 4 decimals, asks "has anything changed since my last download?", and doesn't download again before the time met.no's answer gives (at most 1 hour). In practice that is one download about every hour. +The plugin follows met.no's [terms of service](https://api.met.no/doc/TermsOfService): it sends your email address (only to met.no) as contact, rounds the coordinates to 4 decimals (and sends the altitude, if set), asks "has anything changed since my last download?", and doesn't download again before the time met.no's answer gives (at most 1 hour). In practice that is one download about every hour. ## Layouts @@ -50,6 +50,7 @@ Every layout shows the place: the `place` setting, or the coordinates when it is |---|---|---| | `lat` | none, required | latitude of the place, e.g. `52.52` | | `lon` | none, required | longitude of the place, e.g. `13.40` | +| `altitude` | none | height of the ground at the place, in whole metres (-500 to 9000). met.no then corrects the temperatures for it, which matters in hills and mountains; without it, met.no guesses the height from its own map, which is not detailed enough in hills and mountains | | `email` | none, required | your own, real email address, sent only to met.no. met.no's terms of service require contact details from every program, so it can ask before blocking one that misbehaves | | `place` | `""` | name shown at the top, e.g. `"Berlin"`. Without it, the coordinates are shown ("52.52, 13.40") | | `temperature` | `"C"` | `"C"` (Celsius) or `"F"` (Fahrenheit) | @@ -57,6 +58,8 @@ Every layout shows the place: the `place` setting, or the coordinates when it is Wind is always in knots, because the barbs are drawn in knots. +You don't have to look up `lat`, `lon` and `altitude` yourself: on the plugin's settings page in the web interface, type the name of a town or city just above `lat` (e.g. `Addis Ababa`, or `Morrison, Colorado` when several places share a name), press Search, and **Use** next to the right place fills in `lat`, `lon`, `altitude` and `place` (Save keeps them; you can change the name before saving). The search sends only the typed name to [Open-Meteo](https://open-meteo.com/en/docs/geocoding-api) (place data from GeoNames). + It suggests a refresh every 30 minutes. met.no updates its forecasts about once an hour. In the config file (the rest of the file is shown in the main [README](../../../../README.md)): diff --git a/src/paperpi/plugins/met_no/__init__.py b/src/paperpi/plugins/met_no/__init__.py index 6069cf3..1c309e6 100644 --- a/src/paperpi/plugins/met_no/__init__.py +++ b/src/paperpi/plugins/met_no/__init__.py @@ -16,6 +16,7 @@ from ... import webrequest from ...files import write_atomic +from ...places import location_helper from ...plugin import Context, Plugin, PluginSettings, ready, setting from . import barbs, forecast from .forecast import Forecast, Hour @@ -36,11 +37,24 @@ class Settings(PluginSettings): lat: float | None = setting( - None, required=True, ge=-90, le=90, description="Latitude of the place, e.g. 52.52" + None, + required=True, + ge=-90, + le=90, + helper="location", + description="Latitude of the place, e.g. 52.52", ) lon: float | None = setting( None, required=True, ge=-180, le=180, description="Longitude of the place, e.g. 13.40" ) + altitude: int | None = setting( + None, + ge=-500, + le=9000, + description="Height of the ground at the place, in whole metres, e.g. 34. Makes the " + "temperatures more exact in hills and mountains (else met.no guesses it from its own " + "map)", + ) place: str = Field( "", max_length=60, description="Name shown on the screen, e.g. Berlin (else lat, lon)" ) @@ -80,6 +94,8 @@ def fetch(context: Context): now = datetime.now(UTC) path = context.storage / SAVED place = f"{settings.lat:.4f},{settings.lon:.4f}" + if settings.altitude is not None: + place += f",{settings.altitude}" saved = forecast.load(path) if saved and saved.place != place: saved = None # the place was changed: the saved forecast is for somewhere else @@ -101,9 +117,10 @@ def fetch(context: Context): def download(settings: Settings, place: str, saved: Forecast | None, now: datetime) -> Forecast: # met.no asks for at most 4 decimals: more only makes its caching work less well. - lat, lon = place.split(",") + lat, lon, *altitude = place.split(",") + extra = f"&altitude={altitude[0]}" if altitude else "" answer = webrequest.get( - f"{URL}?lat={lat}&lon={lon}", + f"{URL}?lat={lat}&lon={lon}{extra}", contact=settings.email, if_modified_since=saved.last_modified if saved else None, ) @@ -316,4 +333,5 @@ def _sample() -> Weather: draw=draw, sample=_sample(), refresh=30 * 60, + helpers={"location": location_helper(altitude="altitude", name="place")}, ) diff --git a/src/paperpi/plugins/moon_phase/README.md b/src/paperpi/plugins/moon_phase/README.md index 35ae22f..b57a88f 100644 --- a/src/paperpi/plugins/moon_phase/README.md +++ b/src/paperpi/plugins/moon_phase/README.md @@ -31,6 +31,8 @@ met.no gives one answer per place and day. The plugin saves it in its storage fo | `lon` | none, required | longitude of the place, e.g. `13.40` | | `email` | none, required | your own, real email address, sent only to met.no. met.no's terms of service require contact details from every program, so it can ask before blocking one that misbehaves | +You don't have to look up `lat` and `lon` yourself: on the plugin's settings page in the web interface, type the name of a town or city just above `lat` (e.g. `Addis Ababa`, or `Morrison, Colorado` when several places share a name), press Search, and **Use** next to the right place fills in both (Save keeps them). The search sends only the typed name to [Open-Meteo](https://open-meteo.com/en/docs/geocoding-api) (place data from GeoNames). + It suggests a refresh every 20 minutes. In the config file (the rest of the file is shown in the main [README](../../../../README.md)): diff --git a/src/paperpi/plugins/moon_phase/__init__.py b/src/paperpi/plugins/moon_phase/__init__.py index 065f7b9..0330aae 100644 --- a/src/paperpi/plugins/moon_phase/__init__.py +++ b/src/paperpi/plugins/moon_phase/__init__.py @@ -13,6 +13,7 @@ from ... import webrequest from ...files import write_atomic +from ...places import location_helper from ...plugin import Context, Plugin, PluginSettings, ready, setting from .layouts import LAYOUTS @@ -42,7 +43,12 @@ class Settings(PluginSettings): lat: float | None = setting( - None, required=True, ge=-90, le=90, description="Latitude of the place, e.g. 52.52" + None, + required=True, + ge=-90, + le=90, + helper="location", + description="Latitude of the place, e.g. 52.52", ) lon: float | None = setting( None, required=True, ge=-180, le=180, description="Longitude of the place, e.g. 13.40" @@ -222,4 +228,5 @@ def draw(moon: Moon, context: Context) -> dict: draw=draw, sample=SAMPLE, refresh=20 * 60, + helpers={"location": location_helper()}, ) diff --git a/tests/test_met_no.py b/tests/test_met_no.py index 211a37c..a1e0d6a 100644 --- a/tests/test_met_no.py +++ b/tests/test_met_no.py @@ -9,7 +9,8 @@ from epdlib import Layout, ScreenMode import paperpi.plugins.met_no as met_no -from paperpi import webrequest +from paperpi import places, webrequest +from paperpi.helper import Query from paperpi.plugin import Context from paperpi.plugins.met_no import PLUGIN, Settings, Weather, barbs, draw, fetch, forecast from paperpi.plugins.met_no.forecast import Forecast, ForecastError, Hour @@ -227,6 +228,26 @@ def test_a_changed_place_is_downloaded_again(tmp_path, monkeypatch): assert forecast.load(tmp_path / "forecast.json").place == HERE +def test_the_altitude_is_sent_and_belongs_to_the_place(tmp_path, monkeypatch): + metno = FakeMetNo(monkeypatch) + metno.result = ok(step(0)) + later = datetime.now(UTC) + timedelta(minutes=20) + # A forecast saved without altitude is for another place: it is downloaded again. + saved(tmp_path, timedelta(minutes=5), expires=later) + fetch(context(tmp_path, altitude=34)) + assert metno.calls[0][0] == f"{met_no.URL}?lat=52.5200&lon=13.4000&altitude=34" + assert forecast.load(tmp_path / "forecast.json").place == f"{HERE},34" + + +def test_the_place_search_fills_altitude_and_place(monkeypatch): + rio = places.Place(1, "Rio de Janeiro", "Rio de Janeiro", "Brazil", -22.9064, -43.1822, 4) + monkeypatch.setattr(places, "find_places", lambda text, language: [rio]) + choices = PLUGIN.helper("lat").find(Query("Rio", Settings(), "pt")) + assert [c["fill"] for c in choices] == [ + {"lat": -22.9064, "lon": -43.1822, "altitude": 4, "place": "Rio de Janeiro"} + ] + + def test_a_changed_place_doesnt_use_the_old_forecast_as_fallback(tmp_path, monkeypatch): metno = FakeMetNo(monkeypatch) metno.result = webrequest.WebError("api.met.no answered 503 Service Unavailable") diff --git a/tests/test_moon_phase.py b/tests/test_moon_phase.py index ea4e50f..c4cc244 100644 --- a/tests/test_moon_phase.py +++ b/tests/test_moon_phase.py @@ -10,7 +10,8 @@ from PIL import ImageChops, ImageStat import paperpi.plugins.moon_phase as moon_phase -from paperpi import webrequest +from paperpi import places, webrequest +from paperpi.helper import Query from paperpi.plugin import Context from paperpi.plugins.moon_phase import ( PLUGIN, @@ -318,3 +319,16 @@ def test_draw_a_day_without_moonset(tmp_path): def test_moon_only_gets_only_the_moon(tmp_path): assert set(draw(SAMPLE, context(tmp_path, layout="moon_only"))) == {"moon"} + + +# --- The place search on the settings page --------------------------------------------------- + + +def test_the_place_search_fills_only_lat_and_lon(monkeypatch): + rio = places.Place(1, "Rio de Janeiro", "Rio de Janeiro", "Brazil", -22.9064, -43.1822, 4) + monkeypatch.setattr(places, "find_places", lambda text, language: [rio]) + helper = PLUGIN.helper("lat") + choices = helper.find(Query("Rio", PLUGIN.settings(), "pt")) + # The moon doesn't need the altitude, and moon_phase shows no place name. + assert [c["fill"] for c in choices] == [{"lat": -22.9064, "lon": -43.1822}] + assert choices[0]["show"]["Country"] == "Brazil" diff --git a/tests/test_places.py b/tests/test_places.py new file mode 100644 index 0000000..e4686eb --- /dev/null +++ b/tests/test_places.py @@ -0,0 +1,187 @@ +"""The place search (paperpi.places) and its ready-made location helper, with a fake Open-Meteo.""" + +import json +from urllib.parse import parse_qs, urlsplit + +import pytest + +from paperpi import limits, places, webrequest +from paperpi.helper import HelperProblem, Query, clean +from paperpi.plugin import PluginSettings + +ADDIS = { + "id": 344979, + "name": "Addis Ababa", + "latitude": 9.02497, + "longitude": 38.74689, + "elevation": 2404.6, + "country": "Ethiopia", + "admin1": "Addis Ababa", +} +THE_HAGUE = { + "id": 2747373, + "name": "The Hague", + "latitude": 52.07667, + "longitude": 4.29861, + "elevation": 1.0, + "country": "Netherlands", + "admin1": "South Holland", +} + + +class FakeOpenMeteo: + """Stands in for webrequest.get: answers per language, and records the calls.""" + + def __init__(self, monkeypatch, **answers): + self.answers = answers # language -> list of results, or an error to raise + self.calls = [] + monkeypatch.setattr(places.webrequest, "get", self) + + def __call__(self, url, **options): + query = {key: value[0] for key, value in parse_qs(urlsplit(url).query).items()} + self.calls.append((query, options)) + found = self.answers.get(query["language"], []) + if isinstance(found, Exception): + raise found + # Open-Meteo leaves "results" out when it finds nothing. + body = {"results": found} if found else {"generationtime_ms": 0.5} + return webrequest.Answer(200, json.dumps(body).encode(), {}, url) + + +def test_a_place_is_read_from_the_answer(monkeypatch): + fake = FakeOpenMeteo(monkeypatch, en=[ADDIS]) + assert places.find_places("Addis Ababa") == [ + places.Place(344979, "Addis Ababa", "Addis Ababa", "Ethiopia", 9.025, 38.7469, 2405) + ] + query, options = fake.calls[0] + assert query == {"name": "Addis Ababa", "count": "10", "language": "en", "format": "json"} + # Two requests must fit in one helper search. + assert options == {"total": limits.PLACE_REQUEST, "connect_timeout": limits.PLACE_CONNECT} + assert 2 * limits.PLACE_REQUEST < limits.HELPER_SEARCH + + +def test_missing_region_and_altitude(monkeypatch): + bare = {key: ADDIS[key] for key in ("id", "name", "latitude", "longitude", "country")} + FakeOpenMeteo(monkeypatch, en=[bare]) + (place,) = places.find_places("Addis Ababa") + assert (place.region, place.altitude) == ("", None) + + +@pytest.mark.parametrize( + "odd", + [ + {"id": None}, + {"latitude": None}, + {"longitude": "38.7"}, + {"latitude": float("inf")}, + {"latitude": 91.0}, + {"longitude": True}, + "Addis Ababa", + ], +) +def test_a_place_that_cant_be_read_is_left_out(monkeypatch, odd): + item = ADDIS | odd if isinstance(odd, dict) else odd + FakeOpenMeteo(monkeypatch, en=[item, THE_HAGUE]) + assert [p.name for p in places.find_places("A")] == ["The Hague"] + + +@pytest.mark.parametrize("elevation", [None, float("nan"), float("inf"), "high", False]) +def test_an_odd_altitude_is_not_known(monkeypatch, elevation): + FakeOpenMeteo(monkeypatch, en=[ADDIS | {"elevation": elevation, "admin1": None}]) + (place,) = places.find_places("Addis Ababa") + assert (place.altitude, place.region) == (None, "") + + +@pytest.mark.parametrize("body", [b"[]", b'{"results": {"id": 1}}', b'{"results": null}']) +def test_an_odd_answer_finds_nothing(monkeypatch, body): + monkeypatch.setattr( + places.webrequest, "get", lambda url, **options: webrequest.Answer(200, body, {}, url) + ) + assert places.find_places("Addis Ababa") == [] + + +def test_nothing_found(monkeypatch): + FakeOpenMeteo(monkeypatch) + assert places.find_places("Nowhere at all") == [] + + +def test_the_browsers_language_first_then_english_each_place_once(monkeypatch): + den_haag = THE_HAGUE | {"name": "Den Haag", "admin1": "Zuid-Holland"} + fake = FakeOpenMeteo(monkeypatch, nl=[den_haag], en=[THE_HAGUE, ADDIS]) + found = places.find_places("Den Haag", "nl") + assert [p.name for p in found] == ["Den Haag", "Addis Ababa"] # the Dutch name is kept + assert [query["language"] for query, _ in fake.calls] == ["nl", "en"] + + +def test_english_is_searched_once_and_the_list_is_cut(monkeypatch): + many = [ADDIS | {"id": n} for n in range(10)] + fake = FakeOpenMeteo(monkeypatch, de=many, en=[ADDIS | {"id": 99}]) + # The English-only place (99) is the one left out. + assert [p.id for p in places.find_places("Addis", "de")] == list(range(10)) + places.find_places("Addis", "en") + assert [query["language"] for query, _ in fake.calls] == ["de", "en", "en"] + + +# --- The ready-made location helper ---------------------------------------------------------- + + +def search(helper, text="Addis Ababa", language="en"): + return helper.find(Query(text, PluginSettings(), language)) + + +def test_the_helper_fills_lat_and_lon_and_shows_the_place(monkeypatch): + FakeOpenMeteo(monkeypatch, en=[ADDIS]) + (choice,) = search(places.location_helper()) + assert choice == { + "fill": {"lat": 9.025, "lon": 38.7469}, + "show": { + "Place": "Addis Ababa", + "Region": "Addis Ababa", + "Country": "Ethiopia", + "Altitude": "2405 m", + "Lat, lon": "9.025, 38.7469", + }, + "link": { + "See on map": "https://www.openstreetmap.org/?mlat=9.025&mlon=38.7469" + "#map=12/9.025/38.7469" + }, + } + # It keeps PaperPi's rules for results. + assert clean([choice], ["lat", "lon"]).choices[0].heading == "Addis Ababa" + + +def test_the_helper_fills_the_plugins_own_settings(monkeypatch): + FakeOpenMeteo(monkeypatch, en=[ADDIS]) + helper = places.location_helper("latitude", "longitude", altitude="height", name="place") + (choice,) = search(helper) + assert choice["fill"] == { + "latitude": 9.025, + "longitude": 38.7469, + "height": 2405, + "place": "Addis Ababa", + } + assert isinstance(choice["fill"]["height"], int) # rounded: 2404.6 m + assert helper.source == places.SOURCE and helper.source_link == places.SOURCE_LINK + + +def test_an_unknown_altitude_empties_the_field(monkeypatch): + # Else the altitude of an earlier choice would stay, and be sent for the new place. + FakeOpenMeteo(monkeypatch, en=[{k: v for k, v in ADDIS.items() if k != "elevation"}]) + (choice,) = search(places.location_helper(altitude="altitude")) + assert choice["fill"]["altitude"] == "" and "Altitude" not in choice["show"] + + +def test_when_one_language_fails_the_other_ones_places_are_listed(monkeypatch): + FakeOpenMeteo(monkeypatch, nl=webrequest.WebError("timed out"), en=[ADDIS]) + assert [p.name for p in places.find_places("Addis Ababa", "nl")] == ["Addis Ababa"] + FakeOpenMeteo(monkeypatch, nl=[THE_HAGUE], en=webrequest.WebError("timed out")) + assert [p.name for p in places.find_places("Den Haag", "nl")] == ["The Hague"] + FakeOpenMeteo(monkeypatch, nl=webrequest.WebError("first"), en=webrequest.WebError("last")) + with pytest.raises(webrequest.WebError, match="last"): + places.find_places("Den Haag", "nl") + + +def test_the_helper_says_when_open_meteo_fails(monkeypatch): + FakeOpenMeteo(monkeypatch, en=webrequest.WebError("could not connect")) + with pytest.raises(HelperProblem, match="Open-Meteo could not be reached: could not connect"): + search(places.location_helper()) diff --git a/tests/test_web_forms.py b/tests/test_web_forms.py index 7b51808..fd54d34 100644 --- a/tests/test_web_forms.py +++ b/tests/test_web_forms.py @@ -38,7 +38,7 @@ def test_fields_are_in_groups_in_the_agreed_order(): found = forms.fields(plugins.load("met_no"), WEATHER) assert [f.key for f in found] == [ "name", - *["lat", "lon", "place", "email", "temperature", "rain"], + *["lat", "lon", "altitude", "place", "email", "temperature", "rain"], *["display_time", "refresh", "layout", "level"], *["time_limit", "alert_reminder", "alert_max_time", "storage_mb", "storage_days"], ]