bom-weather-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ """Async client for Australian Bureau of Meteorology weather data."""
2
+
3
+ from ._version import __version__
4
+ from .client import BomClient
5
+ from .const import ICON_DESCRIPTORS
6
+ from .exceptions import (
7
+ BomConnectionError,
8
+ BomError,
9
+ BomNotFoundError,
10
+ BomParseError,
11
+ BomRateLimitError,
12
+ BomResponseError,
13
+ BomTimeoutError,
14
+ )
15
+ from .ftp import FileTransport, FtpTransport
16
+ from .geohash import geohash_encode
17
+ from .models import (
18
+ Coordinate,
19
+ DailyForecast,
20
+ DailyForecastDay,
21
+ ForecastLocation,
22
+ HourlyForecast,
23
+ HourlyForecastPoint,
24
+ NowForecast,
25
+ Observation,
26
+ Station,
27
+ WarningDetails,
28
+ WeatherWarning,
29
+ bearing_to_compass,
30
+ compass_to_bearing,
31
+ icon_descriptor,
32
+ strip_html,
33
+ )
34
+
35
+ __all__ = [
36
+ "ICON_DESCRIPTORS",
37
+ "BomClient",
38
+ "BomConnectionError",
39
+ "BomError",
40
+ "BomNotFoundError",
41
+ "BomParseError",
42
+ "BomRateLimitError",
43
+ "BomResponseError",
44
+ "BomTimeoutError",
45
+ "Coordinate",
46
+ "DailyForecast",
47
+ "DailyForecastDay",
48
+ "FileTransport",
49
+ "ForecastLocation",
50
+ "FtpTransport",
51
+ "HourlyForecast",
52
+ "HourlyForecastPoint",
53
+ "NowForecast",
54
+ "Observation",
55
+ "Station",
56
+ "WarningDetails",
57
+ "WeatherWarning",
58
+ "__version__",
59
+ "bearing_to_compass",
60
+ "compass_to_bearing",
61
+ "geohash_encode",
62
+ "icon_descriptor",
63
+ "strip_html",
64
+ ]
@@ -0,0 +1,3 @@
1
+ """Version of bom-weather-api."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,293 @@
1
+ """Async client for Bureau of Meteorology weather data.
2
+
3
+ Daily forecasts, observations and forecast locations come from the BoM's
4
+ official anonymous FTP products. Hourly forecasts, warnings and forecast
5
+ extras (extended text, UV, fire danger, sun times) come from the JSON API
6
+ that backs the BoM app.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import Callable, Iterable, Mapping
13
+ from dataclasses import replace
14
+ from functools import partial
15
+ import logging
16
+ from typing import TYPE_CHECKING, Any, Self
17
+
18
+ import aiohttp
19
+
20
+ from .const import (
21
+ DEFAULT_TIMEOUT,
22
+ DEFAULT_USER_AGENT,
23
+ FTP_FORECAST_DIR,
24
+ FTP_LOCATIONS_PATH,
25
+ LEGACY_BASE_URL,
26
+ PRECIS_PRODUCTS,
27
+ STATE_PREFIXES,
28
+ )
29
+ from .exceptions import (
30
+ BomConnectionError,
31
+ BomError,
32
+ BomNotFoundError,
33
+ BomParseError,
34
+ BomRateLimitError,
35
+ BomResponseError,
36
+ BomTimeoutError,
37
+ )
38
+ from .ftp import FileTransport, FtpTransport
39
+ from .models import (
40
+ Coordinate,
41
+ DailyForecast,
42
+ ForecastLocation,
43
+ HourlyForecast,
44
+ Observation,
45
+ Station,
46
+ WarningDetails,
47
+ WeatherWarning,
48
+ )
49
+ from .parsers import (
50
+ merge_legacy_daily,
51
+ parse_legacy_hourly,
52
+ parse_legacy_warning,
53
+ parse_legacy_warnings,
54
+ parse_locations,
55
+ parse_observation,
56
+ parse_precis,
57
+ parse_stations,
58
+ )
59
+
60
+ if TYPE_CHECKING:
61
+ from types import TracebackType
62
+
63
+ _LOGGER = logging.getLogger(__name__)
64
+
65
+
66
+ def _state_prefix(state: str) -> str:
67
+ try:
68
+ return STATE_PREFIXES[state.upper()]
69
+ except KeyError as err:
70
+ raise ValueError(f"Unknown state: {state}") from err
71
+
72
+
73
+ class BomClient:
74
+ """Client for Bureau of Meteorology weather data."""
75
+
76
+ def __init__(
77
+ self,
78
+ session: aiohttp.ClientSession | None = None,
79
+ *,
80
+ transport: FileTransport | None = None,
81
+ timeout: float = DEFAULT_TIMEOUT,
82
+ user_agent: str = DEFAULT_USER_AGENT,
83
+ legacy_base_url: str = LEGACY_BASE_URL,
84
+ ) -> None:
85
+ """Initialise the client.
86
+
87
+ Pass an existing ``aiohttp.ClientSession`` to share connections; it is
88
+ never closed by the client.
89
+ """
90
+ self._session = session
91
+ self._owns_session = session is None
92
+ self._transport = transport or FtpTransport(timeout=timeout)
93
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
94
+ self._headers = {"User-Agent": user_agent, "Accept": "application/json"}
95
+ self._legacy_base_url = legacy_base_url.rstrip("/")
96
+
97
+ async def __aenter__(self) -> Self:
98
+ """Enter the async context manager."""
99
+ return self
100
+
101
+ async def __aexit__(
102
+ self,
103
+ exc_type: type[BaseException] | None,
104
+ exc: BaseException | None,
105
+ traceback: TracebackType | None,
106
+ ) -> None:
107
+ """Close the session if the client created it."""
108
+ await self.close()
109
+
110
+ async def close(self) -> None:
111
+ """Close the HTTP session if it is owned by the client."""
112
+ if self._owns_session and self._session is not None:
113
+ await self._session.close()
114
+ self._session = None
115
+
116
+ # --- Transport helpers -------------------------------------------------
117
+
118
+ async def _ftp[T](self, path: str, parser: Callable[[bytes], T]) -> T:
119
+ """Download an FTP product and parse it in a worker thread."""
120
+ content = await self._transport.fetch(path)
121
+ return await asyncio.to_thread(_parse, lambda: parser(content))
122
+
123
+ async def _legacy(self, path: str) -> Mapping[str, Any]:
124
+ """GET a BoM app API path and return the decoded JSON object."""
125
+ if self._session is None:
126
+ self._session = aiohttp.ClientSession()
127
+ try:
128
+ async with self._session.get(
129
+ f"{self._legacy_base_url}{path}",
130
+ headers=self._headers,
131
+ timeout=self._timeout,
132
+ ) as response:
133
+ if response.status == 404:
134
+ raise BomNotFoundError(f"Not found: {path}", status=404)
135
+ if response.status == 429:
136
+ raise BomRateLimitError(
137
+ "Rate limited by the BoM API",
138
+ status=429,
139
+ retry_after=_retry_after(response.headers),
140
+ )
141
+ if response.status >= 400:
142
+ raise BomResponseError(
143
+ f"Unexpected status {response.status} for {path}",
144
+ status=response.status,
145
+ )
146
+ try:
147
+ data = await response.json(content_type=None)
148
+ except ValueError as err:
149
+ raise BomParseError(f"Invalid JSON returned for {path}") from err
150
+ except TimeoutError as err:
151
+ raise BomTimeoutError(f"Timeout requesting {path}") from err
152
+ except aiohttp.ClientError as err:
153
+ raise BomConnectionError(f"Error requesting {path}: {err}") from err
154
+ if not isinstance(data, Mapping):
155
+ raise BomParseError(f"Unexpected response for {path}")
156
+ return data
157
+
158
+ # --- Locations and stations --------------------------------------------
159
+
160
+ async def get_forecast_locations(self) -> list[ForecastLocation]:
161
+ """Return every town BoM issues a précis forecast for (~860 places)."""
162
+ return await self._ftp(FTP_LOCATIONS_PATH, parse_locations)
163
+
164
+ async def get_stations(self, state: str) -> list[Station]:
165
+ """Return the observation stations reporting in a state.
166
+
167
+ ``state`` is an AAC prefix (``NSW``, ``VIC``, ``QLD``, ``SA``, ``WA``,
168
+ ``TAS`` or ``NT``); ACT stations are included in ``NSW``.
169
+ """
170
+ product = f"ID{_state_prefix(state)}60920.xml"
171
+ return await self._ftp(
172
+ f"{FTP_FORECAST_DIR}/{product}",
173
+ partial(parse_stations, state=state.upper()),
174
+ )
175
+
176
+ @staticmethod
177
+ def nearest(
178
+ coordinate: Coordinate,
179
+ items: Iterable[Any],
180
+ *,
181
+ limit: int | None = None,
182
+ ) -> list[tuple[Any, float]]:
183
+ """Sort locations or stations by distance (km) from a coordinate."""
184
+ ranked = sorted(
185
+ (
186
+ (item, round(coordinate.distance_to(item.coordinate), 1))
187
+ for item in items
188
+ if item.coordinate is not None
189
+ ),
190
+ key=lambda pair: pair[1],
191
+ )
192
+ return ranked[:limit] if limit is not None else ranked
193
+
194
+ # --- Observations ------------------------------------------------------
195
+
196
+ async def get_observation(self, state: str, station_id: str) -> Observation:
197
+ """Return the latest observation for a station."""
198
+ product = f"ID{_state_prefix(state)}60920.xml"
199
+ observation = await self._ftp(
200
+ f"{FTP_FORECAST_DIR}/{product}",
201
+ partial(parse_observation, station_id=station_id),
202
+ )
203
+ if observation is None:
204
+ raise BomNotFoundError(f"Station {station_id} is not reporting", status=404)
205
+ return observation
206
+
207
+ # --- Forecasts ---------------------------------------------------------
208
+
209
+ async def get_daily_forecast(
210
+ self, location: ForecastLocation, *, extras: bool = True
211
+ ) -> DailyForecast:
212
+ """Return the daily town forecast for a location.
213
+
214
+ With ``extras=True`` the forecast is enriched with extended text, UV,
215
+ fire danger and sun times from the BoM app API. If that request fails
216
+ the base forecast is still returned.
217
+ """
218
+ product = PRECIS_PRODUCTS[location.product_state]
219
+ forecast_task = self._ftp(
220
+ f"{FTP_FORECAST_DIR}/{product}.xml",
221
+ partial(parse_precis, aac=location.aac),
222
+ )
223
+ if not extras:
224
+ return await forecast_task
225
+ forecast: DailyForecast | BaseException
226
+ legacy: Mapping[str, Any] | BaseException
227
+ forecast, legacy = await asyncio.gather(
228
+ forecast_task,
229
+ self._legacy(f"/locations/{location.coordinate.geohash}/forecasts/daily"),
230
+ return_exceptions=True,
231
+ )
232
+ if isinstance(forecast, BaseException):
233
+ raise forecast
234
+ if isinstance(legacy, BomError):
235
+ _LOGGER.debug("Forecast extras unavailable: %s", legacy)
236
+ return forecast
237
+ if isinstance(legacy, BaseException):
238
+ raise legacy
239
+ return merge_legacy_daily(forecast, legacy)
240
+
241
+ async def get_hourly_forecast(self, coordinate: Coordinate) -> HourlyForecast:
242
+ """Return the hourly forecast for a coordinate (BoM app API)."""
243
+ data = await self._legacy(f"/locations/{coordinate.geohash}/forecasts/hourly")
244
+ return _parse(lambda: parse_legacy_hourly(data))
245
+
246
+ # --- Warnings ----------------------------------------------------------
247
+
248
+ async def get_warnings(
249
+ self, coordinate: Coordinate, *, details: bool = False
250
+ ) -> list[WeatherWarning]:
251
+ """Return current warnings for a coordinate (BoM app API).
252
+
253
+ Cancellations are excluded. With ``details=True`` the plain-text
254
+ message of each warning is fetched as well.
255
+ """
256
+ data = await self._legacy(f"/locations/{coordinate.geohash}/warnings")
257
+ warnings = _parse(lambda: parse_legacy_warnings(data))
258
+ if not details or not warnings:
259
+ return warnings
260
+ results = await asyncio.gather(
261
+ *(self.get_warning(warning.id) for warning in warnings),
262
+ return_exceptions=True,
263
+ )
264
+ enriched: list[WeatherWarning] = []
265
+ for warning, result in zip(warnings, results, strict=True):
266
+ if isinstance(result, BaseException) and not isinstance(result, BomError):
267
+ raise result
268
+ enriched.append(
269
+ replace(warning, message=result.message)
270
+ if isinstance(result, WarningDetails)
271
+ else warning
272
+ )
273
+ return enriched
274
+
275
+ async def get_warning(self, warning_id: str) -> WarningDetails:
276
+ """Return the full text of a warning (BoM app API)."""
277
+ data = await self._legacy(f"/warnings/{warning_id}")
278
+ return _parse(lambda: parse_legacy_warning(data))
279
+
280
+
281
+ def _retry_after(headers: Mapping[str, str]) -> int | None:
282
+ try:
283
+ return int(headers["Retry-After"])
284
+ except (KeyError, ValueError):
285
+ return None
286
+
287
+
288
+ def _parse[T](builder: Callable[[], T]) -> T:
289
+ """Run a parser, converting unexpected response shapes to BomParseError."""
290
+ try:
291
+ return builder()
292
+ except (KeyError, TypeError, AttributeError, ValueError) as err:
293
+ raise BomParseError(f"Unexpected response format: {err}") from err
@@ -0,0 +1,84 @@
1
+ """Constants for the Bureau of Meteorology API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Final
6
+
7
+ from ._version import __version__
8
+
9
+ # Official anonymous FTP products ("free to access and are not for commercial
10
+ # use", see https://www.bom.gov.au/catalogue/data-feeds.shtml).
11
+ FTP_HOST: Final = "ftp.bom.gov.au"
12
+ FTP_FORECAST_DIR: Final = "/anon/gen/fwo"
13
+ FTP_LOCATIONS_PATH: Final = "/anon/home/adfd/spatial/IDM00013.dbf"
14
+
15
+ # Undocumented JSON API that backs the BoM app (hourly forecasts and warnings).
16
+ LEGACY_BASE_URL: Final = "https://api.weather.bom.gov.au/v1"
17
+
18
+ DEFAULT_TIMEOUT: Final = 30.0
19
+ DEFAULT_USER_AGENT: Final = (
20
+ f"bom-weather-api/{__version__} (+https://github.com/tinkerologie/bom-weather-api)"
21
+ )
22
+
23
+ # AAC prefix -> state product identifier prefix. ACT locations use NSW codes.
24
+ STATE_PREFIXES: Final[dict[str, str]] = {
25
+ "NSW": "N",
26
+ "VIC": "V",
27
+ "QLD": "Q",
28
+ "SA": "S",
29
+ "WA": "W",
30
+ "TAS": "T",
31
+ "NT": "D",
32
+ }
33
+
34
+ # Town (précis) forecast product for each state.
35
+ PRECIS_PRODUCTS: Final[dict[str, str]] = {
36
+ "NSW": "IDN11060",
37
+ "VIC": "IDV10753",
38
+ "QLD": "IDQ11295",
39
+ "SA": "IDS10044",
40
+ "WA": "IDW14199",
41
+ "TAS": "IDT16710",
42
+ "NT": "IDD10207",
43
+ }
44
+
45
+ # Official icon codes used by the BoM, mapped to the descriptor names used by
46
+ # the BoM app API.
47
+ ICON_DESCRIPTORS: Final[dict[int, str]] = {
48
+ 1: "sunny",
49
+ 2: "clear",
50
+ 3: "mostly_sunny",
51
+ 4: "cloudy",
52
+ 6: "hazy",
53
+ 8: "light_rain",
54
+ 9: "windy",
55
+ 10: "fog",
56
+ 11: "shower",
57
+ 12: "rain",
58
+ 13: "dusty",
59
+ 14: "frost",
60
+ 15: "snow",
61
+ 16: "storm",
62
+ 17: "light_shower",
63
+ 18: "heavy_shower",
64
+ 19: "cyclone",
65
+ }
66
+
67
+ COMPASS_POINTS: Final[tuple[str, ...]] = (
68
+ "N",
69
+ "NNE",
70
+ "NE",
71
+ "ENE",
72
+ "E",
73
+ "ESE",
74
+ "SE",
75
+ "SSE",
76
+ "S",
77
+ "SSW",
78
+ "SW",
79
+ "WSW",
80
+ "W",
81
+ "WNW",
82
+ "NW",
83
+ "NNW",
84
+ )
@@ -0,0 +1,41 @@
1
+ """Exceptions raised by the Bureau of Meteorology API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class BomError(Exception):
7
+ """Base exception for all client errors."""
8
+
9
+
10
+ class BomConnectionError(BomError):
11
+ """The BoM API could not be reached."""
12
+
13
+
14
+ class BomTimeoutError(BomConnectionError):
15
+ """A request to the BoM API timed out."""
16
+
17
+
18
+ class BomResponseError(BomError):
19
+ """The BoM API returned an unexpected HTTP status."""
20
+
21
+ def __init__(self, message: str, *, status: int) -> None:
22
+ """Initialise the error with the HTTP status code."""
23
+ super().__init__(message)
24
+ self.status = status
25
+
26
+
27
+ class BomNotFoundError(BomResponseError):
28
+ """The requested resource (place, station, warning) does not exist."""
29
+
30
+
31
+ class BomRateLimitError(BomResponseError):
32
+ """The BoM API is rate limiting requests."""
33
+
34
+ def __init__(self, message: str, *, status: int, retry_after: int | None) -> None:
35
+ """Initialise the error with the server supplied retry delay."""
36
+ super().__init__(message, status=status)
37
+ self.retry_after = retry_after
38
+
39
+
40
+ class BomParseError(BomError):
41
+ """The BoM API returned data that could not be parsed."""
bom_weather_api/ftp.py ADDED
@@ -0,0 +1,78 @@
1
+ """Transport for the BoM anonymous FTP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Protocol
7
+
8
+ import aioftp
9
+
10
+ from .const import DEFAULT_TIMEOUT, FTP_HOST
11
+ from .exceptions import BomConnectionError, BomNotFoundError, BomTimeoutError
12
+
13
+ # FTP reply code for "file unavailable".
14
+ _FILE_UNAVAILABLE = "550"
15
+
16
+
17
+ class FileTransport(Protocol):
18
+ """Something that can fetch a file from the BoM FTP server."""
19
+
20
+ async def fetch(self, path: str) -> bytes:
21
+ """Return the contents of the file at ``path``."""
22
+
23
+
24
+ class FtpTransport:
25
+ """Download files from the BoM anonymous FTP server."""
26
+
27
+ def __init__(
28
+ self,
29
+ host: str = FTP_HOST,
30
+ *,
31
+ port: int = 21,
32
+ timeout: float = DEFAULT_TIMEOUT,
33
+ ) -> None:
34
+ """Initialise the transport."""
35
+ self._host = host
36
+ self._port = port
37
+ self._timeout = timeout
38
+
39
+ async def fetch(self, path: str) -> bytes:
40
+ """Download a file using an anonymous FTP session."""
41
+ try:
42
+ async with asyncio.timeout(self._timeout):
43
+ async with aioftp.Client.context(
44
+ self._host,
45
+ self._port,
46
+ socket_timeout=self._timeout,
47
+ ) as client:
48
+ await _ensure_exists(client, path)
49
+ async with client.download_stream(path) as stream:
50
+ chunks = [block async for block in stream.iter_by_block()]
51
+ return b"".join(chunks)
52
+ except TimeoutError as err:
53
+ raise BomTimeoutError(f"Timeout downloading {path}") from err
54
+ except aioftp.StatusCodeError as err:
55
+ if _FILE_UNAVAILABLE in {str(code) for code in err.received_codes}:
56
+ raise BomNotFoundError(f"Not found: {path}", status=550) from err
57
+ raise BomConnectionError(f"FTP error downloading {path}: {err}") from err
58
+ except (OSError, aioftp.AIOFTPException) as err:
59
+ raise BomConnectionError(f"Error downloading {path}: {err}") from err
60
+
61
+
62
+ async def _ensure_exists(client: aioftp.Client, path: str) -> None:
63
+ """Raise a 550 StatusCodeError if ``path`` does not exist.
64
+
65
+ The check runs on the control connection before RETR, because aioftp
66
+ leaks the data connection when RETR is refused.
67
+ """
68
+ await client.command("TYPE I", "200")
69
+ try:
70
+ await client.command(f"SIZE {path}", "213")
71
+ except aioftp.StatusCodeError as err:
72
+ if _FILE_UNAVAILABLE in {str(code) for code in err.received_codes}:
73
+ raise
74
+ # SIZE is not supported by every server; fall back to MLST.
75
+ if not await client.exists(path):
76
+ raise aioftp.StatusCodeError(
77
+ aioftp.Code("213"), aioftp.Code(_FILE_UNAVAILABLE), "not found"
78
+ ) from err
@@ -0,0 +1,29 @@
1
+ """Geohash encoding, used to address locations in the BoM app API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
6
+
7
+
8
+ def geohash_encode(latitude: float, longitude: float, precision: int = 6) -> str:
9
+ """Encode a coordinate as a geohash of the given precision."""
10
+ lat_range = [-90.0, 90.0]
11
+ lon_range = [-180.0, 180.0]
12
+ chars: list[str] = []
13
+ value = bits = 0
14
+ even = True
15
+ while len(chars) < precision:
16
+ bounds, coord = (lon_range, longitude) if even else (lat_range, latitude)
17
+ mid = (bounds[0] + bounds[1]) / 2
18
+ value <<= 1
19
+ if coord > mid:
20
+ value |= 1
21
+ bounds[0] = mid
22
+ else:
23
+ bounds[1] = mid
24
+ even = not even
25
+ bits += 1
26
+ if bits == 5:
27
+ chars.append(_BASE32[value])
28
+ value = bits = 0
29
+ return "".join(chars)