Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/decisions/setting-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions docs/writing-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions paperpi.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/paperpi/limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
151 changes: 151 additions & 0 deletions src/paperpi/places.py
Original file line number Diff line number Diff line change
@@ -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,
)
7 changes: 5 additions & 2 deletions src/paperpi/plugins/met_no/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -50,13 +50,16 @@ 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) |
| `rain` | `"mm"` | `"mm"` or `"inch"` |

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)):
Expand Down
24 changes: 21 additions & 3 deletions src/paperpi/plugins/met_no/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)"
)
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -316,4 +333,5 @@ def _sample() -> Weather:
draw=draw,
sample=_sample(),
refresh=30 * 60,
helpers={"location": location_helper(altitude="altitude", name="place")},
)
2 changes: 2 additions & 0 deletions src/paperpi/plugins/moon_phase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
Expand Down
9 changes: 8 additions & 1 deletion src/paperpi/plugins/moon_phase/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -222,4 +228,5 @@ def draw(moon: Moon, context: Context) -> dict:
draw=draw,
sample=SAMPLE,
refresh=20 * 60,
helpers={"location": location_helper()},
)
23 changes: 22 additions & 1 deletion tests/test_met_no.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading