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
22 changes: 12 additions & 10 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,24 @@ on:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12']
steps:
- uses: actions/checkout@v3

- uses: actions/setup-python@v3
with:
python-version: '3.11'
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install poetry
poetry install

- name: Test with pytest
env:
OWM_API_KEY: ${{ secrets.OWM_API_KEY }}
run: |
source $(poetry env info --path)/bin/activate
pytest

- name: Run offline tests
run: poetry run pytest -m 'not network'

- name: Build package
run: poetry build
77 changes: 76 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,78 @@
# pyopenweathermap

Python library for OpenWeatherMap API for Home Assistant
Python library for OpenWeatherMap API for Home Assistant.

## One Call API

Use `create_owm_client(api_key, 'v3.0')` for the combined One Call 3.0
endpoint, or `create_owm_client(api_key, 'v4.0')` for the separate One Call 4.0
endpoints. Both accept `units` (default `metric`) and `lang` (default `en`),
and expose `await client.get_weather(lat, lon)` returning a `WeatherReport`.
The current/forecast 2.5 and air-pollution clients remain available.

The 4.0 adapter requests `current`, `timeline/1min`, `timeline/1h`, and
`timeline/1day`, then converts their `data` arrays to the existing report
models. It returns up to 60 minute records, 48 hourly records, and 8 daily
records. Missing optional weather fields keep the existing converter defaults;
empty data arrays produce empty report sections. Malformed envelopes/records
raise `RequestError`, and a failed endpoint/page fails the report rather than
silently returning partially refreshed weather.

### Pagination and request budget

A typical complete 4.0 report uses **six HTTP requests**: current, minutely,
three hourly pages (20 + 20 + 8 returned records), and daily. Each page is a
separate API call; check account entitlement and budget before enabling 4.0.
Short pages can require additional requests. The adapter follows `next` only
while it needs records within the requested count/time horizon, stops on an
empty page, removes overlapping records, and rejects repeated links or pages
that make no progress. Daily records are capped at eight; it does not fetch the
full longer-range 4.0 outlook. Requests are sequential and stop at the first
error. No automatic retry is performed.

Only HTTPS pagination links to the same OpenWeather host and timeline endpoint
are accepted. Client coordinates, key, units, and language are preserved on
all pages. HTTP redirects are not followed, and request URL debug logs redact
`appid`. `validate_key()` uses one 4.0 current request (or the legacy 3.0 key
validation call); authorization errors return `False`, and other request errors
are propagated.

### Daily precipitation caveat

The [migration guide](https://openweathermap.org/api/one-call-3-migration#daily)
describes 4.0 daily `rain` and `snow` as scalar daily totals in mm, while the
[reference](https://openweathermap.org/api/one-call-4#daily_parameter) also lists
hourly-shaped `rain.1h` and `snow.1h` objects. This adapter accepts nonnegative
numeric daily totals and defaults absent fields to zero. It raises a descriptive
`RequestError` for an object or other unsupported value. An hourly rate cannot
safely be labeled or multiplied into a daily total. The 3.0 converter behavior
is unchanged. Confirm this ambiguity with a sanitized live response before
claiming production validation.

Reference: [One Call 4.0 endpoints and pagination](https://openweathermap.org/api/one-call-4).
Weather alerts and 15-minute forecasts are outside the existing report model.

## Development and testing

Use Python 3.11+ and Poetry (`poetry install`). The project uses `poetry.lock`.
The default suite and CI are offline and do not require an API key:

```sh
poetry run pytest
poetry run pytest -m 'not network'
poetry build
```

Fixtures are synthetic, source-labeled contract examples, not recordings of
successful authenticated calls. Unmarked tests block socket connections and
DNS. Run credentialed smoke tests only when you have approved account access,
4.0 entitlement, and a request budget; they are explicitly opt-in:

```sh
# Supply OWM_API_KEY through your local environment, not source control.
poetry run pytest -m network
```

The existing skipped legacy smoke tests remain skipped. Offline tests establish
adapter behavior against the published contract; they do not verify an account's
entitlement, actual provider responses, Home Assistant integration, or deployment.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.pytest.ini_options]
addopts = "-m 'not network'"
markers = [
"network: tests that require network access"
]
149 changes: 136 additions & 13 deletions src/pyopenweathermap/client/onecall_client.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
from collections.abc import Callable
from math import isfinite
from typing import TypeVar
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

from .owm_abstract_client import OWMClient
from ..data_converter import DataConverter
from ..exception import UnauthorizedError
from ..exception import RequestError, UnauthorizedError
from ..weather import WeatherReport
from ..air_pollution import AirPollutionReport

API_URL = 'https://api.openweathermap.org/data/3.0/onecall'
V4_API_URL = 'https://api.openweathermap.org/data/4.0/onecall'
WeatherT = TypeVar('WeatherT')


class OWMOneCallClient(OWMClient):
Expand All @@ -16,9 +23,10 @@ def __init__(self, api_key, api_version, units="metric", lang='en'):
self.lang = lang

async def get_weather(self, lat, lon) -> WeatherReport:
url = self._get_url(lat, lon)
json_response = await self.http_client.request(url)
if self.api_version == 'v4.0':
return await self._get_v4_weather(lat, lon)

json_response = await self.http_client.request(self._get_url(lat, lon))
current, minutely, hourly, daily = None, [], [], []
if json_response.get('current') is not None:
current = DataConverter.onecall_to_current_weather(json_response['current'])
Expand All @@ -31,22 +39,137 @@ async def get_weather(self, lat, lon) -> WeatherReport:

return WeatherReport(current, minutely, hourly, daily)

async def _get_v4_weather(self, lat, lon) -> WeatherReport:
response = await self.http_client.request(self._get_url(lat, lon))
rows = self._v4_data(response)
current = self._convert_v4(rows[0], DataConverter.onecall_to_current_weather) if rows else None
# Keep the legacy report horizon. Requests are sequential so failures do
# not leave other billable requests running in the background.
minutely = await self._v4_forecast(
lat, lon, 'timeline/1min', 60, 60,
DataConverter.onecall_to_minutely_weather_forecast,
)
hourly = await self._v4_forecast(
lat, lon, 'timeline/1h', 48, 3600,
DataConverter.onecall_to_hourly_weather_forecast,
)
daily = await self._v4_forecast(
lat, lon, 'timeline/1day', 8, 86400,
DataConverter.onecall_to_daily_weather_forecast,
)
return WeatherReport(current, minutely, hourly, daily)

async def _v4_forecast(
self, lat, lon, endpoint, count, interval, converter: Callable[[dict], WeatherT],
) -> list[WeatherT]:
url = self._get_url(lat, lon, endpoint)
visited = set()
result: list[WeatherT] = []
last_dt = None
end_dt = None
while len(result) < count:
# Canonicalize query order so a reordered repeated link also stops.
identity = (urlsplit(url).path, tuple(sorted(parse_qsl(urlsplit(url).query))))
if identity in visited:
raise RequestError('Repeated One Call 4.0 pagination link')
visited.add(identity)
response = await self.http_client.request(url)
rows = self._v4_data(response)
if not rows:
break
previous_dt = None
added = 0
for row in rows:
dt = row.get('dt')
if not isinstance(dt, int) or isinstance(dt, bool):
raise RequestError('Invalid One Call 4.0 forecast timestamp')
if previous_dt is not None and dt < previous_dt:
raise RequestError('Unordered One Call 4.0 forecast page')
previous_dt = dt
if end_dt is None:
end_dt = dt + count * interval
if dt >= end_dt:
return result
if last_dt is not None and dt <= last_dt:
continue # Pagination may overlap at the page boundary.
if endpoint == 'timeline/1day':
self._validate_v4_daily_precipitation(row)
result.append(self._convert_v4(row, converter))
last_dt = dt
added += 1
if len(result) == count:
return result
next_url = response.get('next')
if next_url is None or next_url == '':
break
if not added:
raise RequestError('One Call 4.0 pagination made no progress')
# Every continued page adds a record, so even changing links cannot
# cause unbounded requests. Stop at the count/time horizon above.
url = self._v4_next_url(next_url, lat, lon, endpoint)
return result

@staticmethod
def _v4_data(response):
if not isinstance(response, dict) or not isinstance(response.get('data'), list):
raise RequestError('Invalid One Call 4.0 response: expected a data array')
if any(not isinstance(row, dict) for row in response['data']):
raise RequestError('Invalid One Call 4.0 response: expected data objects')
return response['data']

@staticmethod
def _convert_v4(row, converter: Callable[[dict], WeatherT]) -> WeatherT:
try:
return converter(row)
except (KeyError, TypeError, ValueError, IndexError, OverflowError, OSError) as error:
raise RequestError('Invalid One Call 4.0 weather record') from error

@staticmethod
def _validate_v4_daily_precipitation(row):
# The migration guide specifies daily scalar totals, but the reference
# also lists rain.1h/snow.1h. Never mislabel an hourly rate as a daily total.
for field in ('rain', 'snow'):
value = row.get(field, 0)
if (not isinstance(value, (int, float)) or isinstance(value, bool)
or (isinstance(value, float) and not isfinite(value)) or value < 0):
raise RequestError(
f'Unsupported One Call 4.0 daily {field}: expected a numeric daily total'
)

def _v4_next_url(self, next_url, lat, lon, endpoint):
if not isinstance(next_url, str):
raise RequestError('Invalid One Call 4.0 pagination link')
try:
parts = urlsplit(next_url)
except ValueError as error:
raise RequestError('Invalid One Call 4.0 pagination link') from error
expected = urlsplit(f'{V4_API_URL}/{endpoint}')
if (parts.scheme != 'https' or parts.netloc != expected.netloc
or parts.path != expected.path or parts.fragment):
raise RequestError('Unsafe One Call 4.0 pagination link')
query = dict(parse_qsl(parts.query, keep_blank_values=True))
# Some documented links omit units/lang. Preserve the caller's options
# and location/key even if a next link omits or changes them.
query.update(self._query_params(lat, lon))
return urlunsplit(parts._replace(query=urlencode(query)))

async def get_air_pollution(self, lat, lon) -> AirPollutionReport:
raise NotImplementedError('free client does not have get_air_pollution()')
raise NotImplementedError('One Call client does not have get_air_pollution()')

async def validate_key(self) -> bool:
url = (f"{self._get_url(50.06, 14.44)}"
f"&exclude=current,minutely,hourly,daily,alerts)")
url = self._get_url(50.06, 14.44)
if self.api_version != 'v4.0':
url += '&exclude=current,minutely,hourly,daily,alerts'
try:
await self.http_client.request(url)
return True
except UnauthorizedError:
return False

def _get_url(self, lat, lon):
return (f"{API_URL}?"
f"lat={lat}&"
f"lon={lon}&"
f"appid={self.api_key}&"
f"units={self.units}&"
f"lang={self.lang}")
def _query_params(self, lat, lon):
return {'lat': lat, 'lon': lon, 'appid': self.api_key,
'units': self.units, 'lang': self.lang}

def _get_url(self, lat, lon, endpoint='current'):
api_url = f'{V4_API_URL}/{endpoint}' if self.api_version == 'v4.0' else API_URL
return f'{api_url}?{urlencode(self._query_params(lat, lon))}'
2 changes: 1 addition & 1 deletion src/pyopenweathermap/client/owm_client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def create_owm_client(api_key, api_type, units="metric", lang='en'):
logger = logging.getLogger(__name__)

logger.info('Initializing OWMClient with api type: ' + str(api_type))
if api_type == 'v3.0':
if api_type == 'v3.0' or api_type == 'v4.0':
return OWMOneCallClient(api_key, api_type, units, lang)
if api_type == 'current' or api_type == 'forecast':
return OWMFreeClient(api_key, api_type, units, lang)
Expand Down
52 changes: 31 additions & 21 deletions src/pyopenweathermap/http_client.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,46 @@
from aiohttp import ClientSession
import logging
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

from aiohttp import ClientSession, ContentTypeError

from .exception import UnauthorizedError, RequestError, TooManyRequestsError
import logging


class HttpClient:
request_timeout: int
logger = logging.getLogger(__name__)

def __init__(self, request_timeout=20):
self.request_timeout = request_timeout
self.request_timeout = request_timeout

async def request(self, url):
self.logger.debug('Requesting url: ' + url)
parts = urlsplit(url)
safe_query = urlencode([(key, '[redacted]' if key == 'appid' else value)
for key, value in parse_qsl(parts.query)])
self.logger.debug('Requesting url: %s', urlunsplit(parts._replace(query=safe_query)))
async with ClientSession() as session:
try:
async with session.get(url=url, timeout=self.request_timeout) as response:
response_json = await response.json()
async with session.get(url=url, timeout=self.request_timeout, allow_redirects=False) as response:
try:
response_json = await response.json()
except (ValueError, ContentTypeError):
# aiohttp content-type errors contain the credentialed
# request URL. Do not expose it through exception chains.
if response.status == 200:
raise RequestError('Invalid JSON response') from None
response_json = {}
if response.status == 200:
return response_json
elif response.status == 400:
raise RequestError(response_json.get('message'))
elif response.status == 401:
raise UnauthorizedError(response_json.get('message'))
elif response.status == 404:
raise RequestError(response_json.get('message'))
elif response.status == 429:
raise TooManyRequestsError(response_json.get('message'))
else:
raise RequestError("Unknown status code: {}".format(response.status))
except RequestError as error:
raise error
message = response_json.get('message') if isinstance(response_json, dict) else None
message = message or f'HTTP status code: {response.status}'
if response.status == 401:
raise UnauthorizedError(message)
if response.status == 429:
raise TooManyRequestsError(message)
raise RequestError(message)
except RequestError:
raise
except TimeoutError:
raise RequestError("Request timeout")
raise RequestError('Request timeout') from None
except Exception as error:
raise RequestError(error) from error
raise RequestError(f'Request failed: {type(error).__name__}') from None
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""Keep tests without the network marker completely offline."""

import socket

import pytest


@pytest.fixture(autouse=True)
def block_unmarked_network(request, monkeypatch):
if request.node.get_closest_marker("network") is not None:
return

def blocked(*args, **kwargs):
raise AssertionError("Network access requires @pytest.mark.network")

monkeypatch.setattr(socket.socket, "connect", blocked)
monkeypatch.setattr(socket.socket, "connect_ex", blocked)
monkeypatch.setattr(socket, "getaddrinfo", blocked)
Loading
Loading