From 9710606c811cdc6220ab59b070022cfd8435cfd6 Mon Sep 17 00:00:00 2001 From: Will Pike <6687499+pike00@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:05:52 -0600 Subject: [PATCH 1/3] Add support for One Call v4.0 endpoint --- src/pyopenweathermap/client/onecall_client.py | 5 ++++- .../client/owm_client_factory.py | 2 +- tests/test_all.py | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/pyopenweathermap/client/onecall_client.py b/src/pyopenweathermap/client/onecall_client.py index 078a8ec..cff548e 100644 --- a/src/pyopenweathermap/client/onecall_client.py +++ b/src/pyopenweathermap/client/onecall_client.py @@ -44,7 +44,10 @@ async def validate_key(self) -> bool: return False def _get_url(self, lat, lon): - return (f"{API_URL}?" + api_url = 'https://api.openweathermap.org/data/3.0/onecall' + if getattr(self, 'api_version', None) == 'v4.0': + api_url = 'https://api.openweathermap.org/data/4.0/onecall' + return (f"{api_url}?" f"lat={lat}&" f"lon={lon}&" f"appid={self.api_key}&" diff --git a/src/pyopenweathermap/client/owm_client_factory.py b/src/pyopenweathermap/client/owm_client_factory.py index e4523fb..34a11d4 100644 --- a/src/pyopenweathermap/client/owm_client_factory.py +++ b/src/pyopenweathermap/client/owm_client_factory.py @@ -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) diff --git a/tests/test_all.py b/tests/test_all.py index 7bca073..697c465 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -20,6 +20,16 @@ async def test_api_30(): assert report.daily_forecast[0].condition.id is not None +@pytest.mark.network +@pytest.mark.asyncio +async def test_api_40(): + api_key = os.getenv('OWM_API_KEY') + client = create_owm_client(api_key, 'v4.0') + report = await client.get_weather(LATITUDE, LONGITUDE) + assert report.current.date_time is not None + assert report.hourly_forecast[0].condition.id is not None + assert report.daily_forecast[0].condition.id is not None + @pytest.mark.network @pytest.mark.asyncio async def test_free_current_weather(): @@ -117,3 +127,9 @@ def test_forecast_deserialization(): weather = DataConverter.free_to_hourly_weather_forecast(data) assert weather.date_time is not None assert weather.condition.id is not None + +def test_v40_url(): + client = create_owm_client('fake_key', 'v4.0') + url = client._get_url(10.0, 20.0) + assert 'https://api.openweathermap.org/data/4.0/onecall' in url + assert 'fake_key' in url From 3e09da0e27a8e16dbc475535712073349e0a79c3 Mon Sep 17 00:00:00 2001 From: Will Pike <6687499+pike00@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:05:57 -0600 Subject: [PATCH 2/3] Update UV flag --- uv.lock | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 uv.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a5bc514 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" From 946529a9c6c9485d8d549f9bec80044294243a8f Mon Sep 17 00:00:00 2001 From: Will Pike <6687499+pike00@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:06:01 -0600 Subject: [PATCH 3/3] Implement documented One Call 4.0 adapter and safe pagination --- .github/workflows/build.yml | 22 +- README.md | 77 +++- pyproject.toml | 1 + src/pyopenweathermap/client/onecall_client.py | 152 ++++++- src/pyopenweathermap/http_client.py | 52 ++- tests/conftest.py | 18 + tests/fixtures/onecall_contract/README.md | 64 +++ .../http_errors_synthetic.json | 33 ++ .../v3_combined_synthetic.json | 101 +++++ .../v4_current_synthetic.json | 9 + ...daily_precipitation_reference_variant.json | 9 + .../onecall_contract/v4_daily_synthetic.json | 16 + .../v4_hourly_page1_synthetic.json | 29 ++ .../v4_hourly_page2_synthetic.json | 29 ++ .../v4_hourly_page3_synthetic.json | 16 + .../v4_minutely_synthetic.json | 11 + tests/test_all.py | 9 +- tests/test_http_error_fixtures.py | 37 ++ tests/test_http_safety.py | 117 +++++ tests/test_onecall_contract.py | 163 +++++++ tests/test_onecall_v4_edgecases.py | 409 ++++++++++++++++++ uv.lock | 3 - 22 files changed, 1322 insertions(+), 55 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/fixtures/onecall_contract/README.md create mode 100644 tests/fixtures/onecall_contract/http_errors_synthetic.json create mode 100644 tests/fixtures/onecall_contract/v3_combined_synthetic.json create mode 100644 tests/fixtures/onecall_contract/v4_current_synthetic.json create mode 100644 tests/fixtures/onecall_contract/v4_daily_precipitation_reference_variant.json create mode 100644 tests/fixtures/onecall_contract/v4_daily_synthetic.json create mode 100644 tests/fixtures/onecall_contract/v4_hourly_page1_synthetic.json create mode 100644 tests/fixtures/onecall_contract/v4_hourly_page2_synthetic.json create mode 100644 tests/fixtures/onecall_contract/v4_hourly_page3_synthetic.json create mode 100644 tests/fixtures/onecall_contract/v4_minutely_synthetic.json create mode 100644 tests/test_http_error_fixtures.py create mode 100644 tests/test_http_safety.py create mode 100644 tests/test_onecall_contract.py create mode 100644 tests/test_onecall_v4_edgecases.py delete mode 100644 uv.lock diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 808bc13..06d6faa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index 7b91145..0af8f2a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index bba2b28..f46ea1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" ] diff --git a/src/pyopenweathermap/client/onecall_client.py b/src/pyopenweathermap/client/onecall_client.py index cff548e..34bff5f 100644 --- a/src/pyopenweathermap/client/onecall_client.py +++ b/src/pyopenweathermap/client/onecall_client.py @@ -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): @@ -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']) @@ -31,25 +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): - api_url = 'https://api.openweathermap.org/data/3.0/onecall' - if getattr(self, 'api_version', None) == 'v4.0': - api_url = 'https://api.openweathermap.org/data/4.0/onecall' - 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))}' diff --git a/src/pyopenweathermap/http_client.py b/src/pyopenweathermap/http_client.py index 1f47c83..5ad2981 100644 --- a/src/pyopenweathermap/http_client.py +++ b/src/pyopenweathermap/http_client.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7e138cf --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/fixtures/onecall_contract/README.md b/tests/fixtures/onecall_contract/README.md new file mode 100644 index 0000000..ba029e0 --- /dev/null +++ b/tests/fixtures/onecall_contract/README.md @@ -0,0 +1,64 @@ +# One Call contract fixtures + +Every JSON file here is **synthetic**. Values, coordinates, timestamps, weather, +IDs, messages, and pagination links are test inputs, not captured API responses. +`fixture-not-a-key` is deliberately nonfunctional. No account or credential was +read and no authenticated OpenWeather call was made to produce these fixtures. + +## Provenance and intent + +- `v3_combined_synthetic.json`: combined 3.0 section/field contract with one + current, minute, hour, and day record; regression coverage for legacy parsing. +- `v4_current_synthetic.json`, `v4_minutely_synthetic.json`: dedicated endpoint + `data` envelopes. Minute data is intentionally a three-record excerpt. +- `v4_hourly_page{1,2,3}_synthetic.json`: 48 consecutive hourly records in pages + of 20, 20, and 8. Links use `start` and a nonfunctional test key. +- `v4_daily_synthetic.json`: eight days preserving the legacy forecast horizon. + Scalar daily precipitation follows the migration guide; missing optional + fields exercise converter defaults. +- `v4_daily_precipitation_reference_variant.json`: follows the conflicting + reference's daily `rain.1h`/`snow.1h` shape. The v4 adapter deliberately raises + `RequestError` because an hourly rate cannot be treated as a daily total. + This does not resolve which shape the live service uses. +- `http_errors_synthetic.json`: 400/401/404/429 bodies in the documented error + envelope shape, used to exercise local HTTP exception mapping. + +## Primary sources (checked 2026-10-13) + +- [3.0 combined contract](https://openweathermap.org/api/one-call-3#how) +- [Migration checklist](https://openweathermap.org/api/one-call-3-migration#checklist) +- [Migration field mappings](https://openweathermap.org/api/one-call-3-migration#current) +- [4.0 current response](https://openweathermap.org/api/one-call-4#example) +- [4.0 minute response](https://openweathermap.org/api/one-call-4#min_example) +- [4.0 hourly limit](https://openweathermap.org/api/one-call-4#hourly_example) +- [4.0 daily response/limit](https://openweathermap.org/api/one-call-4#daily_example) +- [4.0 pagination](https://openweathermap.org/api/one-call-4#pagination) +- Conflicting daily precipitation descriptions: + [migration guide](https://openweathermap.org/api/one-call-3-migration#daily) + versus [reference fields](https://openweathermap.org/api/one-call-4#daily_parameter) +- [4.0 error structure](https://openweathermap.org/api/one-call-4#errors) + +## Run offline + +Use Python 3.11+ and the repository's Poetry dependencies: + +```sh +poetry install +poetry run pytest +``` + +Equivalent with test dependencies installed into a virtual environment: + +```sh +PYTHONPATH=src python -m pytest -q -m 'not network' +``` + +The five former strict-xfail acceptance cases now pass without xfail markers. +They cover current/minute/hour/day envelopes and the separate endpoint + +pagination flow. Obsolete characterization assertions for the broken bare +4.0 endpoint have been replaced with working-contract assertions. Edge tests +cover malformed/empty pages, failures, bounded horizons, safe pagination, +optional fields, and explicit rejection of ambiguous daily precipitation. +Default tests and CI are offline; live tests require `-m network` explicitly. +The fixtures establish local behavior only, not authenticated API or Home +Assistant integration evidence. diff --git a/tests/fixtures/onecall_contract/http_errors_synthetic.json b/tests/fixtures/onecall_contract/http_errors_synthetic.json new file mode 100644 index 0000000..d0ab23f --- /dev/null +++ b/tests/fixtures/onecall_contract/http_errors_synthetic.json @@ -0,0 +1,33 @@ +[ + { + "status": 400, + "body": { + "cod": 400, + "message": "Latitude is outside the allowed range", + "parameters": [ + "lat" + ] + } + }, + { + "status": 401, + "body": { + "cod": 401, + "message": "Synthetic key is not authorized" + } + }, + { + "status": 404, + "body": { + "cod": 404, + "message": "Synthetic route not found" + } + }, + { + "status": 429, + "body": { + "cod": 429, + "message": "Synthetic request limit exceeded" + } + } +] diff --git a/tests/fixtures/onecall_contract/v3_combined_synthetic.json b/tests/fixtures/onecall_contract/v3_combined_synthetic.json new file mode 100644 index 0000000..588ac17 --- /dev/null +++ b/tests/fixtures/onecall_contract/v3_combined_synthetic.json @@ -0,0 +1,101 @@ +{ + "lat": 51.5, + "lon": -0.1, + "timezone": "Europe/London", + "timezone_offset": 3600, + "current": { + "dt": 1780300800, + "temp": 12.5, + "feels_like": 11.0, + "pressure": 1013, + "humidity": 70, + "dew_point": 7.2, + "uvi": 1.2, + "clouds": 75, + "visibility": 9000, + "wind_speed": 3.5, + "wind_deg": 180, + "rain": { + "1h": 0.4 + }, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + }, + "minutely": [ + { + "dt": 1780300800, + "precipitation": 0.4 + } + ], + "hourly": [ + { + "dt": 1780300800, + "temp": 12.5, + "feels_like": 11.0, + "pressure": 1013, + "humidity": 70, + "dew_point": 7.2, + "uvi": 1.2, + "clouds": 75, + "visibility": 9000, + "wind_speed": 3.5, + "wind_deg": 180, + "rain": { + "1h": 0.4 + }, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "pop": 0.6, + "wind_gust": 5.0 + } + ], + "daily": [ + { + "dt": 1780300800, + "temp": { + "day": 12.5, + "min": 8.0, + "max": 14.0, + "night": 9.0, + "eve": 11.0, + "morn": 8.5 + }, + "feels_like": { + "day": 11.0, + "night": 8.0, + "eve": 10.0, + "morn": 7.0 + }, + "pressure": 1013, + "humidity": 70, + "dew_point": 7.2, + "uvi": 2.0, + "clouds": 75, + "wind_speed": 3.5, + "wind_deg": 180, + "wind_gust": 5.0, + "pop": 0.6, + "rain": 2.4, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ] + } + ] +} diff --git a/tests/fixtures/onecall_contract/v4_current_synthetic.json b/tests/fixtures/onecall_contract/v4_current_synthetic.json new file mode 100644 index 0000000..86f1e03 --- /dev/null +++ b/tests/fixtures/onecall_contract/v4_current_synthetic.json @@ -0,0 +1,9 @@ +{ + "lat": 51.5, + "lon": -0.1, + "timezone": "Europe/London", + "timezone_offset": 3600, + "data": [ + {"dt": 1780300800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "alerts": ["synthetic-alert-id"]} + ] +} diff --git a/tests/fixtures/onecall_contract/v4_daily_precipitation_reference_variant.json b/tests/fixtures/onecall_contract/v4_daily_precipitation_reference_variant.json new file mode 100644 index 0000000..467e53c --- /dev/null +++ b/tests/fixtures/onecall_contract/v4_daily_precipitation_reference_variant.json @@ -0,0 +1,9 @@ +{ + "lat": 51.5, + "lon": -0.1, + "timezone": "Europe/London", + "timezone_offset": 3600, + "data": [ + {"dt": 1780300800, "temp": {"day": 12.5, "min": 8.0, "max": 14.0, "night": 9.0, "eve": 11.0, "morn": 8.5}, "feels_like": {"day": 11.0, "night": 8.0, "eve": 10.0, "morn": 7.0}, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 2.0, "clouds": 75, "wind_speed": 3.5, "wind_deg": 180, "wind_gust": 5.0, "pop": 0.6, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "snow": {"1h": 0.2}} + ] +} diff --git a/tests/fixtures/onecall_contract/v4_daily_synthetic.json b/tests/fixtures/onecall_contract/v4_daily_synthetic.json new file mode 100644 index 0000000..c752e8f --- /dev/null +++ b/tests/fixtures/onecall_contract/v4_daily_synthetic.json @@ -0,0 +1,16 @@ +{ + "lat": 51.5, + "lon": -0.1, + "timezone": "Europe/London", + "timezone_offset": 3600, + "data": [ + {"dt": 1780300800, "temp": {"day": 12.5, "min": 8.0, "max": 14.0, "night": 9.0, "eve": 11.0, "morn": 8.5}, "feels_like": {"day": 11.0, "night": 8.0, "eve": 10.0, "morn": 7.0}, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 2.0, "clouds": 75, "wind_speed": 3.5, "wind_deg": 180, "wind_gust": 5.0, "pop": 0.6, "rain": 2.4, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}]}, + {"dt": 1780387200, "temp": {"day": 12.5, "min": 8.0, "max": 14.0, "night": 9.0, "eve": 11.0, "morn": 8.5}, "feels_like": {"day": 11.0, "night": 8.0, "eve": 10.0, "morn": 7.0}, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 2.0, "clouds": 75, "wind_speed": 3.5, "wind_deg": 180, "wind_gust": 5.0, "pop": 0.6, "rain": 2.4, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}]}, + {"dt": 1780473600, "temp": {"day": 12.5, "min": 8.0, "max": 14.0, "night": 9.0, "eve": 11.0, "morn": 8.5}, "feels_like": {"day": 11.0, "night": 8.0, "eve": 10.0, "morn": 7.0}, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 2.0, "clouds": 75, "wind_speed": 3.5, "wind_deg": 180, "wind_gust": 5.0, "pop": 0.6, "rain": 2.4, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}]}, + {"dt": 1780560000, "temp": {"day": 12.5, "min": 8.0, "max": 14.0, "night": 9.0, "eve": 11.0, "morn": 8.5}, "feels_like": {"day": 11.0, "night": 8.0, "eve": 10.0, "morn": 7.0}, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 2.0, "clouds": 75, "wind_speed": 3.5, "wind_deg": 180, "wind_gust": 5.0, "pop": 0.6, "rain": 2.4, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}]}, + {"dt": 1780646400, "temp": {"day": 12.5, "min": 8.0, "max": 14.0, "night": 9.0, "eve": 11.0, "morn": 8.5}, "feels_like": {"day": 11.0, "night": 8.0, "eve": 10.0, "morn": 7.0}, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 2.0, "clouds": 75, "wind_speed": 3.5, "wind_deg": 180, "wind_gust": 5.0, "pop": 0.6, "rain": 2.4, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}]}, + {"dt": 1780732800, "temp": {"day": 12.5, "min": 8.0, "max": 14.0, "night": 9.0, "eve": 11.0, "morn": 8.5}, "feels_like": {"day": 11.0, "night": 8.0, "eve": 10.0, "morn": 7.0}, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 2.0, "clouds": 75, "wind_speed": 3.5, "wind_deg": 180, "wind_gust": 5.0, "pop": 0.6, "rain": 2.4, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}]}, + {"dt": 1780819200, "temp": {"day": 12.5, "min": 8.0, "max": 14.0, "night": 9.0, "eve": 11.0, "morn": 8.5}, "feels_like": {"day": 11.0, "night": 8.0, "eve": 10.0, "morn": 7.0}, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 2.0, "clouds": 75, "wind_speed": 3.5, "wind_deg": 180, "wind_gust": 5.0, "pop": 0.6, "rain": 2.4, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}]}, + {"dt": 1780905600, "temp": {"day": 12.5, "min": 8.0, "max": 14.0, "night": 9.0, "eve": 11.0, "morn": 8.5}, "feels_like": {"day": 11.0, "night": 8.0, "eve": 10.0, "morn": 7.0}, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 2.0, "clouds": 75, "wind_speed": 3.5, "wind_deg": 180, "wind_gust": 5.0, "pop": 0.6, "rain": 2.4, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}]} + ] +} diff --git a/tests/fixtures/onecall_contract/v4_hourly_page1_synthetic.json b/tests/fixtures/onecall_contract/v4_hourly_page1_synthetic.json new file mode 100644 index 0000000..1601b8c --- /dev/null +++ b/tests/fixtures/onecall_contract/v4_hourly_page1_synthetic.json @@ -0,0 +1,29 @@ +{ + "lat": 51.5, + "lon": -0.1, + "timezone": "Europe/London", + "timezone_offset": 3600, + "next": "https://api.openweathermap.org/data/4.0/onecall/timeline/1h?lat=51.5&lon=-0.1&start=1780372800&appid=fixture-not-a-key&units=metric&lang=en", + "data": [ + {"dt": 1780300800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780304400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780308000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780311600, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780315200, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780318800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780322400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780326000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780329600, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780333200, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780336800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780340400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780344000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780347600, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780351200, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780354800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780358400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780362000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780365600, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780369200, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0} + ] +} diff --git a/tests/fixtures/onecall_contract/v4_hourly_page2_synthetic.json b/tests/fixtures/onecall_contract/v4_hourly_page2_synthetic.json new file mode 100644 index 0000000..1ee98f7 --- /dev/null +++ b/tests/fixtures/onecall_contract/v4_hourly_page2_synthetic.json @@ -0,0 +1,29 @@ +{ + "lat": 51.5, + "lon": -0.1, + "timezone": "Europe/London", + "timezone_offset": 3600, + "next": "https://api.openweathermap.org/data/4.0/onecall/timeline/1h?lat=51.5&lon=-0.1&start=1780444800&appid=fixture-not-a-key&units=metric&lang=en", + "data": [ + {"dt": 1780372800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780376400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780380000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780383600, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780387200, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780390800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780394400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780398000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780401600, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780405200, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780408800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780412400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780416000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780419600, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780423200, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780426800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780430400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780434000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780437600, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780441200, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0} + ] +} diff --git a/tests/fixtures/onecall_contract/v4_hourly_page3_synthetic.json b/tests/fixtures/onecall_contract/v4_hourly_page3_synthetic.json new file mode 100644 index 0000000..c963a46 --- /dev/null +++ b/tests/fixtures/onecall_contract/v4_hourly_page3_synthetic.json @@ -0,0 +1,16 @@ +{ + "lat": 51.5, + "lon": -0.1, + "timezone": "Europe/London", + "timezone_offset": 3600, + "data": [ + {"dt": 1780444800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780448400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780452000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780455600, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780459200, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780462800, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780466400, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0}, + {"dt": 1780470000, "temp": 12.5, "feels_like": 11.0, "pressure": 1013, "humidity": 70, "dew_point": 7.2, "uvi": 1.2, "clouds": 75, "visibility": 9000, "wind_speed": 3.5, "wind_deg": 180, "rain": {"1h": 0.4}, "weather": [{"id": 500, "main": "Rain", "description": "light rain", "icon": "10d"}], "pop": 0.6, "wind_gust": 5.0} + ] +} diff --git a/tests/fixtures/onecall_contract/v4_minutely_synthetic.json b/tests/fixtures/onecall_contract/v4_minutely_synthetic.json new file mode 100644 index 0000000..5fa76ba --- /dev/null +++ b/tests/fixtures/onecall_contract/v4_minutely_synthetic.json @@ -0,0 +1,11 @@ +{ + "lat": 51.5, + "lon": -0.1, + "timezone": "Europe/London", + "timezone_offset": 3600, + "data": [ + {"dt": 1780300800, "precipitation": 0.4}, + {"dt": 1780300860, "precipitation": 0.4}, + {"dt": 1780300920, "precipitation": 0.4} + ] +} diff --git a/tests/test_all.py b/tests/test_all.py index 697c465..b26298c 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -37,8 +37,8 @@ async def test_free_current_weather(): client = create_owm_client(api_key, 'current') report = await client.get_weather(LATITUDE, LONGITUDE) assert report.current.date_time is not None - assert len(report.hourly_forecast) is 0 - assert len(report.daily_forecast) is 0 + assert len(report.hourly_forecast) == 0 + assert len(report.daily_forecast) == 0 @pytest.mark.network @@ -50,7 +50,7 @@ async def test_free_forecast_weather(): report = await client.get_weather(LATITUDE, LONGITUDE) assert report.current is None assert report.hourly_forecast[0].temperature is not None - assert len(report.daily_forecast) is 0 + assert len(report.daily_forecast) == 0 @pytest.mark.network @pytest.mark.asyncio @@ -68,6 +68,7 @@ async def test_air_pollution(): assert report.hourly_forecast[0].co is not None +@pytest.mark.network @pytest.mark.asyncio async def test_request_error(): api_key = os.getenv('OWM_API_KEY') @@ -131,5 +132,5 @@ def test_forecast_deserialization(): def test_v40_url(): client = create_owm_client('fake_key', 'v4.0') url = client._get_url(10.0, 20.0) - assert 'https://api.openweathermap.org/data/4.0/onecall' in url + assert url.startswith('https://api.openweathermap.org/data/4.0/onecall/current?') assert 'fake_key' in url diff --git a/tests/test_http_error_fixtures.py b/tests/test_http_error_fixtures.py new file mode 100644 index 0000000..174b27c --- /dev/null +++ b/tests/test_http_error_fixtures.py @@ -0,0 +1,37 @@ +"""HTTP status mapping without credentials, sessions, or network requests.""" + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from pyopenweathermap import RequestError, TooManyRequestsError, UnauthorizedError +from pyopenweathermap.http_client import HttpClient + +CASES = json.loads((Path(__file__).parent / "fixtures" / "onecall_contract" / + "http_errors_synthetic.json").read_text()) + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: str(case["status"])) +@pytest.mark.asyncio +async def test_http_error_status_from_fixture(monkeypatch, case): + response = MagicMock(status=case["status"]) + response.json = AsyncMock(return_value=case["body"]) + response.__aenter__.return_value = response + session = MagicMock() + session.__aenter__.return_value = session + session.get.return_value = response + constructor = MagicMock(return_value=session) + monkeypatch.setattr("pyopenweathermap.http_client.ClientSession", constructor) + expected = {400: RequestError, 401: UnauthorizedError, + 404: RequestError, 429: TooManyRequestsError}[case["status"]] + + with pytest.raises(expected) as error: + await HttpClient().request("https://example.invalid/offline-fixture") + + assert type(error.value) is expected + assert str(error.value) == case["body"]["message"] + session.get.assert_called_once_with( + url="https://example.invalid/offline-fixture", timeout=20, allow_redirects=False) + response.json.assert_awaited_once() diff --git a/tests/test_http_safety.py b/tests/test_http_safety.py new file mode 100644 index 0000000..ffa2662 --- /dev/null +++ b/tests/test_http_safety.py @@ -0,0 +1,117 @@ +"""Credential logging and redirect safety, with all HTTP sessions mocked.""" + +import logging +import traceback +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import parse_qs, urlsplit + +import pytest +from aiohttp import ClientConnectionError, ContentTypeError + +from pyopenweathermap import RequestError, TooManyRequestsError, UnauthorizedError +from pyopenweathermap.http_client import HttpClient + + +def stub_session(monkeypatch, status=200, body=None): + response = MagicMock(status=status) + response.json = AsyncMock(return_value={} if body is None else body) + response.__aenter__.return_value = response + session = MagicMock() + session.__aenter__.return_value = session + session.get.return_value = response + monkeypatch.setattr("pyopenweathermap.http_client.ClientSession", MagicMock(return_value=session)) + return session, response + + +@pytest.mark.asyncio +async def test_debug_logs_redact_every_appid_but_request_keeps_original_url(monkeypatch, caplog): + session, _ = stub_session(monkeypatch, body={"data": []}) + url = ("https://api.openweathermap.org/data/4.0/onecall/current?" + "lat=51.5&appid=secret-one&lon=-0.1&appid=secret%2Btwo&units=imperial&lang=fr") + with caplog.at_level(logging.DEBUG, logger="pyopenweathermap.http_client"): + assert await HttpClient(request_timeout=7).request(url) == {"data": []} + session.get.assert_called_once_with(url=url, timeout=7, allow_redirects=False) + assert "secret-one" not in caplog.text + assert "secret%2Btwo" not in caplog.text + assert "secret+two" not in caplog.text + request_logs = [record.getMessage() for record in caplog.records if "Requesting url:" in record.getMessage()] + assert len(request_logs) == 1 + logged_url = request_logs[0].split("Requesting url: ", 1)[1] + query = parse_qs(urlsplit(logged_url).query) + assert query["appid"] == ["[redacted]", "[redacted]"] + assert query["units"] == ["imperial"] + assert query["lang"] == ["fr"] + + +@pytest.mark.parametrize("status", [301, 302, 303, 307, 308]) +@pytest.mark.asyncio +async def test_redirects_are_disabled_and_never_treated_as_success(monkeypatch, status): + session, response = stub_session(monkeypatch, status=status) + response.headers = {"Location": "https://example.invalid/collect-key"} + url = "https://api.openweathermap.org/data/4.0/onecall/current?appid=synthetic-key" + with pytest.raises(RequestError): + await HttpClient().request(url) + session.get.assert_called_once_with(url=url, timeout=20, allow_redirects=False) + + +@pytest.mark.asyncio +async def test_timeout_is_mapped_to_request_error(monkeypatch): + _, response = stub_session(monkeypatch) + response.__aenter__.side_effect = TimeoutError() + with pytest.raises(RequestError, match="Request timeout"): + await HttpClient().request("https://example.invalid/offline") + + +@pytest.mark.parametrize("failure_kind", ["invalid-json", "content-type"]) +@pytest.mark.parametrize("status,error_type", [ + (200, RequestError), (400, RequestError), (401, UnauthorizedError), + (404, RequestError), (429, TooManyRequestsError), (500, RequestError), +]) +@pytest.mark.asyncio +async def test_non_json_responses_keep_status_mapping_without_leaking_credentials( + monkeypatch, failure_kind, status, error_type): + _, response = stub_session(monkeypatch, status=status) + secret = "synthetic-private-key" + url = "https://example.invalid/offline?appid=" + secret + failure = (ValueError("Invalid JSON at " + url) if failure_kind == "invalid-json" + else ContentTypeError(SimpleNamespace(real_url=url), (), + status=status, message="Unexpected mimetype")) + response.json.side_effect = failure + with pytest.raises(error_type) as error: + await HttpClient().request(url) + assert type(error.value) is error_type + assert secret not in str(error.value) + assert secret not in "".join(traceback.format_exception(error.value)) + if status == 200: + assert str(error.value) == "Invalid JSON response" + else: + assert str(status) in str(error.value) + + +@pytest.mark.parametrize("body", [None, [], "bad gateway", {"unrelated": "value"}]) +@pytest.mark.parametrize("status,error_type", [(401, UnauthorizedError), (429, TooManyRequestsError), (503, RequestError)]) +@pytest.mark.asyncio +async def test_http_error_without_message_keeps_status_mapping(monkeypatch, body, status, error_type): + _, response = stub_session(monkeypatch, status=status) + response.json.return_value = body + with pytest.raises(error_type) as error: + await HttpClient().request("https://example.invalid/offline") + assert type(error.value) is error_type + assert str(status) in str(error.value) + + +@pytest.mark.parametrize("error_type", [TimeoutError, ClientConnectionError, RuntimeError]) +@pytest.mark.asyncio +async def test_transport_exception_text_and_chain_do_not_expose_key(monkeypatch, error_type, caplog): + session, _ = stub_session(monkeypatch) + secret = "synthetic-transport-secret" + url = "https://example.invalid/offline?appid=" + secret + session.get.side_effect = error_type("Request failed for " + url) + with caplog.at_level(logging.DEBUG, logger="pyopenweathermap.http_client"): + with pytest.raises(RequestError) as error: + await HttpClient().request(url) + assert secret not in str(error.value) + assert secret not in "".join(traceback.format_exception(error.value)) + assert secret not in caplog.text + session.get.assert_called_once_with(url=url, timeout=20, allow_redirects=False) diff --git a/tests/test_onecall_contract.py b/tests/test_onecall_contract.py new file mode 100644 index 0000000..9b8e256 --- /dev/null +++ b/tests/test_onecall_contract.py @@ -0,0 +1,163 @@ +"""Offline regression tests for the documented One Call 3.0/4.0 contracts.""" + +import json +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import AsyncMock +from urllib.parse import parse_qs, urlsplit + +import pytest + +from pyopenweathermap import create_owm_client +from pyopenweathermap.data_converter import DataConverter + +FIXTURES = Path(__file__).parent / "fixtures" / "onecall_contract" +KEY = "fixture-not-a-key" + + +def load_fixture(name): + return json.loads((FIXTURES / name).read_text()) + + +def stub_client(monkeypatch, payload, version="v4.0"): + client = create_owm_client(KEY, version) + request = AsyncMock(return_value=payload) + monkeypatch.setattr(client.http_client, "request", request) + return client, request + + +@pytest.mark.parametrize("version", ["v3.0", "v4.0"]) +def test_existing_url_preserves_request_options(version): + client = create_owm_client(KEY, version, units="imperial", lang="fr") + url = urlsplit(client._get_url(51.5, -0.1)) + assert url.scheme == "https" + assert url.netloc == "api.openweathermap.org" + suffix = "/current" if version == "v4.0" else "" + assert url.path == f"/data/{version[1:]}/onecall{suffix}" + assert parse_qs(url.query) == { + "lat": ["51.5"], "lon": ["-0.1"], "appid": [KEY], + "units": ["imperial"], "lang": ["fr"], + } + + +@pytest.mark.asyncio +async def test_legacy_combined_envelope_regression(monkeypatch): + payload = load_fixture("v3_combined_synthetic.json") + client, request = stub_client(monkeypatch, payload, "v3.0") + report = await client.get_weather(51.5, -0.1) + request.assert_awaited_once_with(client._get_url(51.5, -0.1)) + assert report.current.date_time == datetime.fromtimestamp(1780300800, tz=UTC) + assert report.current.temperature == 12.5 + assert report.current.rain == {"1h": 0.4} + assert report.current.wind_gust is None + assert report.current.snow is None + assert report.minutely_forecast[0].precipitation == 0.4 + assert report.hourly_forecast[0].precipitation_probability == 0.6 + assert report.hourly_forecast[0].wind_gust == 5.0 + assert report.daily_forecast[0].temperature.min == 8.0 + assert report.daily_forecast[0].feels_like.min is None + assert report.daily_forecast[0].summary is None + assert report.daily_forecast[0].rain == 2.4 + assert report.daily_forecast[0].snow == 0 + + +@pytest.mark.asyncio +async def test_missing_legacy_sections_remain_optional(monkeypatch): + client, _ = stub_client(monkeypatch, {"current": None}, "v3.0") + report = await client.get_weather(51.5, -0.1) + assert report.current is None + assert report.minutely_forecast == [] + assert report.hourly_forecast == [] + assert report.daily_forecast == [] + + +@pytest.mark.parametrize("fixture,converter,field,expected", [ + ("v4_current_synthetic.json", DataConverter.onecall_to_current_weather, + "temperature", 12.5), + ("v4_minutely_synthetic.json", DataConverter.onecall_to_minutely_weather_forecast, + "precipitation", 0.4), + ("v4_hourly_page1_synthetic.json", DataConverter.onecall_to_hourly_weather_forecast, + "precipitation_probability", 0.6), + ("v4_daily_synthetic.json", DataConverter.onecall_to_daily_weather_forecast, + "rain", 2.4), +]) +def test_individual_records_can_reuse_converters(fixture, converter, field, expected): + """The row-level field mappings remain compatible with legacy converters.""" + row = load_fixture(fixture)["data"][0] + converted = converter(row) + assert getattr(converted, field) == expected + assert converted.date_time == datetime.fromtimestamp(row["dt"], tz=UTC) + + +def test_pagination_fixture_has_complete_48_hour_window(): + pages = [load_fixture(f"v4_hourly_page{n}_synthetic.json") for n in (1, 2, 3)] + assert [len(page["data"]) for page in pages] == [20, 20, 8] + rows = [row for page in pages for row in page["data"]] + times = [row["dt"] for row in rows] + assert len(set(times)) == 48 + assert all(b - a == 3600 for a, b in zip(times, times[1:])) + for page, following in zip(pages, pages[1:]): + assert int(parse_qs(urlsplit(page["next"]).query)["start"][0]) == following["data"][0]["dt"] + assert "next" not in pages[-1] + + +def stub_documented_api(monkeypatch): + """Serve separate endpoints and all three synthetic hourly pages.""" + client = create_owm_client(KEY, "v4.0") + prefix = "/data/4.0/onecall" + pages = [load_fixture(f"v4_hourly_page{n}_synthetic.json") for n in (1, 2, 3)] + by_path = { + prefix + "/current": load_fixture("v4_current_synthetic.json"), + prefix + "/timeline/1min": load_fixture("v4_minutely_synthetic.json"), + prefix + "/timeline/1h": pages[0], + prefix + "/timeline/1day": load_fixture("v4_daily_synthetic.json"), + } + following_pages = {pages[0]["next"]: pages[1], pages[1]["next"]: pages[2]} + calls = [] + + async def request(url): + calls.append(url) + if url in following_pages: + return following_pages[url] + # An undocumented route must fail the test. + return by_path[urlsplit(url).path] + + monkeypatch.setattr(client.http_client, "request", request) + return client, calls, pages + + +@pytest.mark.asyncio +async def test_required_v4_current_envelope_parsing(monkeypatch): + client, _, _ = stub_documented_api(monkeypatch) + report = await client.get_weather(51.5, -0.1) + assert report.current is not None + assert report.current.temperature == 12.5 + + +@pytest.mark.parametrize("attribute,count", [ + ("minutely_forecast", 3), ("hourly_forecast", 48), ("daily_forecast", 8), +]) +@pytest.mark.asyncio +async def test_required_v4_forecast_envelope_parsing(monkeypatch, attribute, count): + client, _, _ = stub_documented_api(monkeypatch) + report = await client.get_weather(51.5, -0.1) + assert len(getattr(report, attribute)) == count + + +@pytest.mark.asyncio +async def test_required_v4_endpoints_and_complete_forecast_horizon(monkeypatch): + """Acceptance target: keep the legacy 48-hour/8-day WeatherReport contract.""" + client, calls, pages = stub_documented_api(monkeypatch) + prefix = "/data/4.0/onecall" + report = await client.get_weather(51.5, -0.1) + assert {urlsplit(url).path for url in calls} == { + prefix + "/current", prefix + "/timeline/1min", + prefix + "/timeline/1h", prefix + "/timeline/1day", + } + assert report.current.temperature == 12.5 + assert len(report.minutely_forecast) == 3 + assert len(report.hourly_forecast) == 48 + assert len(report.daily_forecast) == 8 + assert pages[0]["next"] in calls + assert pages[1]["next"] in calls + assert len(calls) == 6 diff --git a/tests/test_onecall_v4_edgecases.py b/tests/test_onecall_v4_edgecases.py new file mode 100644 index 0000000..23ace05 --- /dev/null +++ b/tests/test_onecall_v4_edgecases.py @@ -0,0 +1,409 @@ +"""Offline edge cases for the v4 adapter using synthetic, non-live records.""" + +from copy import deepcopy +import json +from pathlib import Path +from urllib.parse import parse_qs, urlencode, urlsplit + +import pytest + +from pyopenweathermap import ( + RequestError, TooManyRequestsError, UnauthorizedError, create_owm_client, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "onecall_contract" +BASE = "https://api.openweathermap.org/data/4.0/onecall/" +KEY = "synthetic key &+only" +LAT, LON = 51.5, -0.1 +FORECASTS = [ + ("timeline/1min", "minutely_forecast", "v4_minutely_synthetic.json", 60, 60), + ("timeline/1h", "hourly_forecast", "v4_hourly_page1_synthetic.json", 48, 3600), + ("timeline/1day", "daily_forecast", "v4_daily_synthetic.json", 8, 86400), +] +ENDPOINTS = ["current", *(case[0] for case in FORECASTS)] + + +def fixture(name): + return json.loads((FIXTURES / name).read_text()) + + +def row_for(endpoint): + name = ("v4_current_synthetic.json" if endpoint == "current" else + next(case[2] for case in FORECASTS if case[0] == endpoint)) + return fixture(name)["data"][0] + + +def rows_for(endpoint, offsets, interval): + template = row_for(endpoint) + return [dict(deepcopy(template), dt=template["dt"] + i * interval) + for i in offsets] + + +def stub_api(monkeypatch, pages=None, version="v4.0"): + """Unexpected requests fail instead of returning an endlessly reusable page.""" + pages = pages or {} + client = create_owm_client(KEY, version, units="imperial", lang="fr") + calls = [] + used = {} + + async def request(url): + calls.append(url) + path = urlsplit(url).path + if version == "v3.0": + endpoint = "combined" + assert path == "/data/3.0/onecall" + else: + prefix = "/data/4.0/onecall/" + assert path.startswith(prefix) + endpoint = path[len(prefix):] + assert endpoint in ENDPOINTS + index = used.get(endpoint, 0) + used[endpoint] = index + 1 + available = pages.get(endpoint, [{"data": []}]) + assert index < len(available), f"Unexpected extra request to {endpoint}" + payload = available[index] + if isinstance(payload, Exception): + raise payload + return deepcopy(payload) + + monkeypatch.setattr(client.http_client, "request", request) + return client, calls + + +def endpoint_calls(calls, endpoint): + return [url for url in calls if urlsplit(url).path.endswith("/" + endpoint)] + + +UNSAFE_NEXT = [ + "http://api.openweathermap.org/data/4.0/onecall/timeline/1h?start=1", + "//api.openweathermap.org/data/4.0/onecall/timeline/1h?start=1", + "/data/4.0/onecall/timeline/1h?start=1", + "?start=1", + "https://example.invalid/data/4.0/onecall/timeline/1h?start=1", + "https://api.openweathermap.org.example.invalid/data/4.0/onecall/timeline/1h", + "https://api.openweathermap.org@example.invalid/data/4.0/onecall/timeline/1h", + "https://user@api.openweathermap.org/data/4.0/onecall/timeline/1h", + "https://api.openweathermap.org:8443/data/4.0/onecall/timeline/1h", + BASE + "current?start=1", + BASE + "timeline/1min?start=1", + BASE + "timeline/1h/../1day?start=1", + BASE + "timeline/1h%2f..%2f1day?start=1", + BASE + "timeline/1h?start=1#fragment", + "https://[malformed/data/4.0/onecall/timeline/1h", + 42, False, [], {}, +] + + +@pytest.mark.parametrize("next_link", UNSAFE_NEXT) +@pytest.mark.asyncio +async def test_unsafe_or_malformed_next_is_rejected_before_request(monkeypatch, next_link): + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": [row_for("timeline/1h")], "next": next_link}, + ]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + assert len(endpoint_calls(calls, "timeline/1h")) == 1 + assert not endpoint_calls(calls, "timeline/1day") + assert all(urlsplit(url).netloc == "api.openweathermap.org" for url in calls) + + +@pytest.mark.parametrize("next_query", [ + "start=1780304400", + "start=1780304400&units=standard&lang=de&lat=0&lon=0&appid=wrong&appid=other", +]) +@pytest.mark.asyncio +async def test_next_preserves_caller_location_key_units_and_language(monkeypatch, next_query): + rows = rows_for("timeline/1h", range(2), 3600) + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": rows[:1], "next": BASE + "timeline/1h?" + next_query}, + {"data": rows[1:]}, + ]}) + report = await client.get_weather(LAT, LON) + assert len(report.hourly_forecast) == 2 + assert len(endpoint_calls(calls, "timeline/1h")) == 2 + for url in calls: + query = parse_qs(urlsplit(url).query) + assert {name: query[name] for name in ("lat", "lon", "appid", "units", "lang")} == { + "lat": [str(LAT)], "lon": [str(LON)], "appid": [KEY], + "units": ["imperial"], "lang": ["fr"], + } + assert parse_qs(urlsplit(endpoint_calls(calls, "timeline/1h")[1]).query)["start"] == ["1780304400"] + + +@pytest.mark.parametrize("endpoint,attribute,name,count,interval", FORECASTS) +@pytest.mark.parametrize("next_value", [None, ""]) +@pytest.mark.asyncio +async def test_short_pages_return_available_rows(monkeypatch, endpoint, attribute, name, + count, interval, next_value): + client, calls = stub_api(monkeypatch, {endpoint: [ + {"data": rows_for(endpoint, range(2), interval), "next": next_value}, + ]}) + report = await client.get_weather(LAT, LON) + assert len(getattr(report, attribute)) == 2 + assert len(endpoint_calls(calls, endpoint)) == 1 + + +@pytest.mark.asyncio +async def test_missing_next_finishes_without_inventing_another_page(monkeypatch): + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": [row_for("timeline/1h")]}, + ]}) + report = await client.get_weather(LAT, LON) + assert len(report.hourly_forecast) == 1 + assert len(calls) == 4 + + +@pytest.mark.asyncio +async def test_empty_envelopes_are_optional_and_do_not_follow_next(monkeypatch): + client, calls = stub_api(monkeypatch, { + endpoint: [{"data": [], "next": BASE + endpoint + "?start=1"}] + for endpoint in ENDPOINTS + }) + report = await client.get_weather(LAT, LON) + assert report.current is None + assert report.minutely_forecast == report.hourly_forecast == report.daily_forecast == [] + assert len(calls) == 4 + + +@pytest.mark.parametrize("endpoint", ENDPOINTS) +@pytest.mark.parametrize("payload", [ + None, [], {}, {"data": None}, {"data": {}}, {"data": "bad"}, + {"data": [None]}, {"data": [[]]}, {"data": ["bad"]}, {"data": [{}]}, +]) +@pytest.mark.asyncio +async def test_malformed_envelope_or_record_raises_request_error(monkeypatch, endpoint, payload): + client, calls = stub_api(monkeypatch, {endpoint: [payload]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + assert len(calls) == ENDPOINTS.index(endpoint) + 1 + + +@pytest.mark.parametrize("endpoint,attribute,name,count,interval", FORECASTS) +@pytest.mark.parametrize("timestamp", [None, True, False, 1780300800.5, "1780300800", []]) +@pytest.mark.asyncio +async def test_invalid_forecast_timestamps_raise_request_error( + monkeypatch, endpoint, attribute, name, count, interval, timestamp): + row = row_for(endpoint) + row["dt"] = timestamp + client, _ = stub_api(monkeypatch, {endpoint: [{"data": [row]}]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + + +@pytest.mark.asyncio +async def test_malformed_following_page_is_not_silently_returned_as_partial_report(monkeypatch): + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": [row_for("timeline/1h")], "next": BASE + "timeline/1h?start=2"}, + {"unexpected": []}, + ]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + assert len(endpoint_calls(calls, "timeline/1h")) == 2 + + +@pytest.mark.asyncio +async def test_overlapping_and_duplicate_rows_are_deduplicated_in_time_order(monkeypatch): + rows = rows_for("timeline/1h", range(4), 3600) + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": [rows[0], rows[0], rows[1]], "next": BASE + "timeline/1h?start=2"}, + {"data": [rows[0], rows[1], rows[2], rows[2], rows[3]]}, + ]}) + report = await client.get_weather(LAT, LON) + assert [int(row.date_time.timestamp()) for row in report.hourly_forecast] == [row["dt"] for row in rows] + assert len(endpoint_calls(calls, "timeline/1h")) == 2 + + +@pytest.mark.asyncio +async def test_out_of_order_page_is_rejected(monkeypatch): + client, _ = stub_api(monkeypatch, {"timeline/1h": [ + {"data": rows_for("timeline/1h", [1, 0], 3600)}, + ]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + + +@pytest.mark.asyncio +async def test_reordered_query_cycle_is_detected_before_request(monkeypatch): + query = {"lang": "fr", "units": "imperial", "appid": KEY, "lon": LON, "lat": LAT} + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": [row_for("timeline/1h")], "next": BASE + "timeline/1h?" + urlencode(query)}, + ]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + assert len(endpoint_calls(calls, "timeline/1h")) == 1 + + +@pytest.mark.asyncio +async def test_longer_pagination_cycle_is_bounded(monkeypatch): + rows = rows_for("timeline/1h", range(3), 3600) + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": [rows[0]], "next": BASE + "timeline/1h?start=1"}, + {"data": [rows[1]], "next": BASE + "timeline/1h?start=2"}, + {"data": [rows[2]], "next": BASE + "timeline/1h?start=1"}, + ]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + assert len(endpoint_calls(calls, "timeline/1h")) == 3 + + +@pytest.mark.asyncio +async def test_nonprogressing_page_cannot_keep_requesting_new_links(monkeypatch): + row = row_for("timeline/1h") + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": [row], "next": BASE + "timeline/1h?start=1"}, + {"data": [row], "next": BASE + "timeline/1h?start=2"}, + ]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + assert len(endpoint_calls(calls, "timeline/1h")) == 2 + + +@pytest.mark.parametrize("endpoint,attribute,name,count,interval", FORECASTS) +@pytest.mark.parametrize("page_size", [1, 100]) +@pytest.mark.asyncio +async def test_forecast_count_caps_bound_rows_and_requests( + monkeypatch, endpoint, attribute, name, count, interval, page_size): + rows = rows_for(endpoint, range(count + 4), interval) + pages = [{"data": rows[i:i + page_size], "next": BASE + endpoint + f"?start={i + page_size}"} + for i in range(0, len(rows), page_size)] + client, calls = stub_api(monkeypatch, {endpoint: pages}) + report = await client.get_weather(LAT, LON) + actual = getattr(report, attribute) + assert len(actual) == count + assert [int(row.date_time.timestamp()) for row in actual] == [row["dt"] for row in rows[:count]] + assert len(endpoint_calls(calls, endpoint)) == (count + page_size - 1) // page_size + + +@pytest.mark.parametrize("endpoint,attribute,name,count,interval", FORECASTS) +@pytest.mark.asyncio +async def test_sparse_forecasts_stop_at_time_horizon_even_before_count_cap( + monkeypatch, endpoint, attribute, name, count, interval): + rows = rows_for(endpoint, [0, count - 1, count, count + 1], interval) + client, calls = stub_api(monkeypatch, {endpoint: [ + {"data": rows[:1], "next": BASE + endpoint + "?start=2"}, + {"data": rows[1:], "next": BASE + endpoint + "?start=3"}, + ]}) + report = await client.get_weather(LAT, LON) + assert [int(row.date_time.timestamp()) for row in getattr(report, attribute)] == [row["dt"] for row in rows[:2]] + assert len(endpoint_calls(calls, endpoint)) == 2 + + +@pytest.mark.parametrize("field", ["rain", "snow"]) +@pytest.mark.parametrize("value", [0, 2, 0.0, 2.4, 10 ** 400]) +@pytest.mark.asyncio +async def test_numeric_daily_precipitation_totals_are_preserved(monkeypatch, field, value): + row = row_for("timeline/1day") + row[field] = value + client, _ = stub_api(monkeypatch, {"timeline/1day": [{"data": [row]}]}) + report = await client.get_weather(LAT, LON) + assert getattr(report.daily_forecast[0], field) == value + + +@pytest.mark.asyncio +async def test_absent_daily_precipitation_defaults_to_zero(monkeypatch): + row = row_for("timeline/1day") + row.pop("rain", None) + row.pop("snow", None) + client, _ = stub_api(monkeypatch, {"timeline/1day": [{"data": [row]}]}) + report = await client.get_weather(LAT, LON) + assert report.daily_forecast[0].rain == report.daily_forecast[0].snow == 0 + + +@pytest.mark.parametrize("field", ["rain", "snow"]) +@pytest.mark.parametrize("value", [ + {"1h": 0.4}, {}, [], True, False, None, -1, -0.5, "2.4", float("nan"), float("inf"), +]) +@pytest.mark.asyncio +async def test_invalid_daily_precipitation_is_not_mislabeled_as_daily_total( + monkeypatch, field, value): + row = row_for("timeline/1day") + row[field] = value + client, _ = stub_api(monkeypatch, {"timeline/1day": [{"data": [row]}]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + + +@pytest.mark.asyncio +async def test_reference_daily_precipitation_object_variant_is_rejected(monkeypatch): + payload = fixture("v4_daily_precipitation_reference_variant.json") + client, _ = stub_api(monkeypatch, {"timeline/1day": [payload]}) + with pytest.raises(RequestError): + await client.get_weather(LAT, LON) + + +@pytest.mark.parametrize("endpoint", ENDPOINTS) +@pytest.mark.parametrize("error_type", [RequestError, UnauthorizedError, TooManyRequestsError]) +@pytest.mark.asyncio +async def test_request_errors_propagate_without_remaining_endpoint_requests( + monkeypatch, endpoint, error_type): + expected = error_type("synthetic service failure") + client, calls = stub_api(monkeypatch, {endpoint: [expected]}) + with pytest.raises(error_type) as error: + await client.get_weather(LAT, LON) + assert error.value is expected + assert len(calls) == ENDPOINTS.index(endpoint) + 1 + + +@pytest.mark.parametrize("version", ["v3.0", "v4.0"]) +@pytest.mark.parametrize("failure", [None, UnauthorizedError, RequestError, TooManyRequestsError]) +@pytest.mark.asyncio +async def test_validate_key_uses_version_endpoint_and_only_swallows_unauthorized( + monkeypatch, version, failure): + endpoint = "current" if version == "v4.0" else "combined" + response = {} if failure is None else failure("synthetic failure") + client, calls = stub_api(monkeypatch, {endpoint: [response]}, version=version) + if failure is None or failure is UnauthorizedError: + assert await client.validate_key() is (failure is None) + else: + with pytest.raises(failure) as error: + await client.validate_key() + assert error.value is response + assert len(calls) == 1 + parts = urlsplit(calls[0]) + expected_path = "/data/4.0/onecall/current" if version == "v4.0" else "/data/3.0/onecall" + assert parts.path == expected_path + query = parse_qs(parts.query) + assert query["appid"] == [KEY] + assert query["units"] == ["imperial"] + assert query["lang"] == ["fr"] + if version == "v4.0": + assert "exclude" not in query + else: + assert query["exclude"] == ["current,minutely,hourly,daily,alerts"] + + +@pytest.mark.asyncio +async def test_empty_later_page_finishes_with_preceding_records(monkeypatch): + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": [row_for("timeline/1h")], "next": BASE + "timeline/1h?start=2"}, + {"data": [], "next": BASE + "timeline/1h?start=3"}, + ]}) + report = await client.get_weather(LAT, LON) + assert len(report.hourly_forecast) == 1 + assert len(endpoint_calls(calls, "timeline/1h")) == 2 + + +@pytest.mark.parametrize("error_type", [RequestError, UnauthorizedError, TooManyRequestsError]) +@pytest.mark.asyncio +async def test_paginated_request_error_is_propagated_without_partial_success(monkeypatch, error_type): + expected = error_type("synthetic second-page failure") + client, calls = stub_api(monkeypatch, {"timeline/1h": [ + {"data": [row_for("timeline/1h")], "next": BASE + "timeline/1h?start=2"}, + expected, + ]}) + with pytest.raises(error_type) as error: + await client.get_weather(LAT, LON) + assert error.value is expected + assert len(endpoint_calls(calls, "timeline/1h")) == 2 + assert not endpoint_calls(calls, "timeline/1day") + + +@pytest.mark.parametrize("error_type", [RequestError, UnauthorizedError, TooManyRequestsError]) +@pytest.mark.asyncio +async def test_v3_weather_request_errors_still_propagate(monkeypatch, error_type): + expected = error_type("synthetic v3 failure") + client, calls = stub_api(monkeypatch, {"combined": [expected]}, version="v3.0") + with pytest.raises(error_type) as error: + await client.get_weather(LAT, LON) + assert error.value is expected + assert len(calls) == 1 diff --git a/uv.lock b/uv.lock deleted file mode 100644 index a5bc514..0000000 --- a/uv.lock +++ /dev/null @@ -1,3 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.14"