python-google-weather-api 0.0.8__tar.gz → 0.0.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_google_weather_api-0.0.8/src/python_google_weather_api.egg-info → python_google_weather_api-0.0.10}/PKG-INFO +1 -1
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/pyproject.toml +5 -2
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/google_weather_api/__init__.py +2 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/google_weather_api/api.py +29 -20
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/google_weather_api/exceptions.py +4 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/google_weather_api/model.py +7 -6
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10/src/python_google_weather_api.egg-info}/PKG-INFO +1 -1
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/tests/test_api.py +91 -2
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/tests/test_model.py +11 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/LICENSE +0 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/README.md +0 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/setup.cfg +0 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/google_weather_api/py.typed +0 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/python_google_weather_api.egg-info/SOURCES.txt +0 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/python_google_weather_api.egg-info/dependency_links.txt +0 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/python_google_weather_api.egg-info/requires.txt +0 -0
- {python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/python_google_weather_api.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-google-weather-api"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.10"
|
|
4
4
|
license = "Apache-2.0"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name="tronikos", email="tronikos@gmail.com" },
|
|
@@ -31,7 +31,10 @@ build-backend = "setuptools.build_meta"
|
|
|
31
31
|
|
|
32
32
|
[tool.pytest.ini_options]
|
|
33
33
|
asyncio_mode = "auto"
|
|
34
|
-
addopts = "--cov=google_weather_api --cov-report=term-missing --cov-fail-under=90"
|
|
34
|
+
addopts = "-m 'not network' --cov=google_weather_api --cov-report=term-missing --cov-fail-under=90"
|
|
35
|
+
markers = [
|
|
36
|
+
"network: tests that hit the live Google Weather API (deselected by default)",
|
|
37
|
+
]
|
|
35
38
|
|
|
36
39
|
[tool.coverage.run]
|
|
37
40
|
branch = true
|
|
@@ -4,6 +4,7 @@ from .api import GoogleWeatherApi
|
|
|
4
4
|
from .exceptions import (
|
|
5
5
|
GoogleWeatherApiAuthError,
|
|
6
6
|
GoogleWeatherApiConnectionError,
|
|
7
|
+
GoogleWeatherApiConnectionTimeoutError,
|
|
7
8
|
GoogleWeatherApiError,
|
|
8
9
|
GoogleWeatherApiResponseError,
|
|
9
10
|
)
|
|
@@ -50,6 +51,7 @@ __all__ = [
|
|
|
50
51
|
"GoogleWeatherApi",
|
|
51
52
|
"GoogleWeatherApiAuthError",
|
|
52
53
|
"GoogleWeatherApiConnectionError",
|
|
54
|
+
"GoogleWeatherApiConnectionTimeoutError",
|
|
53
55
|
"GoogleWeatherApiError",
|
|
54
56
|
"GoogleWeatherApiResponseError",
|
|
55
57
|
"HourlyForecastResponse",
|
{python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/google_weather_api/api.py
RENAMED
|
@@ -11,6 +11,7 @@ import aiohttp
|
|
|
11
11
|
from .exceptions import (
|
|
12
12
|
GoogleWeatherApiAuthError,
|
|
13
13
|
GoogleWeatherApiConnectionError,
|
|
14
|
+
GoogleWeatherApiConnectionTimeoutError,
|
|
14
15
|
GoogleWeatherApiResponseError,
|
|
15
16
|
)
|
|
16
17
|
from .model import (
|
|
@@ -23,6 +24,8 @@ from .model import (
|
|
|
23
24
|
_LOGGER = logging.getLogger(__name__)
|
|
24
25
|
|
|
25
26
|
_BASE_URL = "https://weather.googleapis.com/v1"
|
|
27
|
+
# See https://docs.cloud.google.com/docs/authentication/api-keys-use
|
|
28
|
+
_API_KEY_HEADER = "X-Goog-Api-Key"
|
|
26
29
|
_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
|
27
30
|
|
|
28
31
|
|
|
@@ -49,7 +52,10 @@ class GoogleWeatherApi:
|
|
|
49
52
|
async def _async_get(self, endpoint: str, params: dict[str, Any], *, include_language_code: bool = True) -> dict[str, Any]:
|
|
50
53
|
"""Perform a GET request."""
|
|
51
54
|
url = f"{_BASE_URL}/{endpoint}"
|
|
52
|
-
|
|
55
|
+
# Send the API key in a header rather than the "key" query parameter so it
|
|
56
|
+
# never ends up in a URL, which aiohttp includes in exceptions such as
|
|
57
|
+
# ContentTypeError and which callers commonly log.
|
|
58
|
+
headers = {aiohttp.hdrs.USER_AGENT: _USER_AGENT, _API_KEY_HEADER: self.api_key}
|
|
53
59
|
if self.referrer:
|
|
54
60
|
headers[aiohttp.hdrs.REFERER] = self.referrer
|
|
55
61
|
params = {
|
|
@@ -58,9 +64,7 @@ class GoogleWeatherApi:
|
|
|
58
64
|
}
|
|
59
65
|
if include_language_code:
|
|
60
66
|
params["language_code"] = self.language_code
|
|
61
|
-
# Log before adding the API key so it doesn't end up in debug logs.
|
|
62
67
|
_LOGGER.debug("GET %s with params: %s", url, params)
|
|
63
|
-
params["key"] = self.api_key
|
|
64
68
|
try:
|
|
65
69
|
async with self.session.get(
|
|
66
70
|
url,
|
|
@@ -68,28 +72,33 @@ class GoogleWeatherApi:
|
|
|
68
72
|
headers=headers,
|
|
69
73
|
timeout=self.timeout,
|
|
70
74
|
) as resp:
|
|
71
|
-
res: dict[str, Any] = await resp.json()
|
|
72
75
|
_LOGGER.debug("Got %s for %s", resp.status, url)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
error_msg = error_data.get("message", "Unknown API error")
|
|
76
|
-
|
|
77
|
-
if resp.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
|
78
|
-
raise GoogleWeatherApiAuthError(error_msg)
|
|
79
|
-
|
|
80
|
-
# Google APIs often return 400 Bad Request for invalid API keys
|
|
81
|
-
if resp.status == HTTPStatus.BAD_REQUEST:
|
|
82
|
-
for detail in error_data.get("details", []):
|
|
83
|
-
if detail.get("reason") == "API_KEY_INVALID":
|
|
84
|
-
raise GoogleWeatherApiAuthError(error_msg)
|
|
85
|
-
|
|
86
|
-
raise GoogleWeatherApiResponseError(error_msg)
|
|
87
|
-
return res
|
|
76
|
+
status_code = resp.status
|
|
77
|
+
body = None if status_code == HTTPStatus.GATEWAY_TIMEOUT else await resp.json()
|
|
88
78
|
except TimeoutError as err:
|
|
89
|
-
raise
|
|
79
|
+
raise GoogleWeatherApiConnectionTimeoutError("Timeout") from err
|
|
90
80
|
except aiohttp.ClientError as err:
|
|
91
81
|
raise GoogleWeatherApiConnectionError(err) from err
|
|
92
82
|
|
|
83
|
+
if status_code == HTTPStatus.GATEWAY_TIMEOUT:
|
|
84
|
+
raise GoogleWeatherApiConnectionTimeoutError("Timeout")
|
|
85
|
+
res: dict[str, Any] = body if isinstance(body, dict) else {}
|
|
86
|
+
if status_code != HTTPStatus.OK:
|
|
87
|
+
error_data = res.get("error", {})
|
|
88
|
+
error_msg = error_data.get("message", "Unknown API error")
|
|
89
|
+
|
|
90
|
+
if status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
|
|
91
|
+
raise GoogleWeatherApiAuthError(error_msg)
|
|
92
|
+
|
|
93
|
+
# Google APIs often return 400 Bad Request for invalid API keys
|
|
94
|
+
if status_code == HTTPStatus.BAD_REQUEST:
|
|
95
|
+
for detail in error_data.get("details", []):
|
|
96
|
+
if detail.get("reason") == "API_KEY_INVALID":
|
|
97
|
+
raise GoogleWeatherApiAuthError(error_msg)
|
|
98
|
+
|
|
99
|
+
raise GoogleWeatherApiResponseError(error_msg)
|
|
100
|
+
return res
|
|
101
|
+
|
|
93
102
|
async def _async_get_all_pages(
|
|
94
103
|
self,
|
|
95
104
|
endpoint: str,
|
|
@@ -9,6 +9,10 @@ class GoogleWeatherApiConnectionError(GoogleWeatherApiError):
|
|
|
9
9
|
"""Exception connecting to the Google Weather API."""
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
class GoogleWeatherApiConnectionTimeoutError(GoogleWeatherApiConnectionError, TimeoutError):
|
|
13
|
+
"""Exception raised when a request to the Google Weather API times out."""
|
|
14
|
+
|
|
15
|
+
|
|
12
16
|
class GoogleWeatherApiAuthError(GoogleWeatherApiError):
|
|
13
17
|
"""Exception raised for authentication errors in the Google Weather API."""
|
|
14
18
|
|
{python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/google_weather_api/model.py
RENAMED
|
@@ -42,6 +42,7 @@ class BaseModel(DataClassJSONMixin):
|
|
|
42
42
|
|
|
43
43
|
serialize_by_alias = True
|
|
44
44
|
allow_deserialization_not_by_alias = True
|
|
45
|
+
omit_none = True
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
@dataclass
|
|
@@ -523,12 +524,12 @@ class ForecastDay(BaseModel):
|
|
|
523
524
|
class DailyForecastResponse(BaseModel):
|
|
524
525
|
"""Response model for the forecast.days.lookup method."""
|
|
525
526
|
|
|
526
|
-
forecast_days: list[ForecastDay] = field(metadata={"alias": "forecastDays"})
|
|
527
|
-
"""The daily forecast records."""
|
|
528
|
-
|
|
529
527
|
time_zone: TimeZone = field(metadata={"alias": "timeZone"})
|
|
530
528
|
"""The time zone at the requested location."""
|
|
531
529
|
|
|
530
|
+
forecast_days: list[ForecastDay] = field(default_factory=list, metadata={"alias": "forecastDays"})
|
|
531
|
+
"""The daily forecast records."""
|
|
532
|
+
|
|
532
533
|
next_page_token: str | None = field(default=None, metadata={"alias": "nextPageToken"})
|
|
533
534
|
"""The token to retrieve the next page."""
|
|
534
535
|
|
|
@@ -631,12 +632,12 @@ class ForecastHour(BaseModel):
|
|
|
631
632
|
class HourlyForecastResponse(BaseModel):
|
|
632
633
|
"""Response model for the forecast.hours.lookup method."""
|
|
633
634
|
|
|
634
|
-
forecast_hours: list[ForecastHour] = field(metadata={"alias": "forecastHours"})
|
|
635
|
-
"""The hourly forecast records."""
|
|
636
|
-
|
|
637
635
|
time_zone: TimeZone = field(metadata={"alias": "timeZone"})
|
|
638
636
|
"""The time zone at the requested location."""
|
|
639
637
|
|
|
638
|
+
forecast_hours: list[ForecastHour] = field(default_factory=list, metadata={"alias": "forecastHours"})
|
|
639
|
+
"""The hourly forecast records."""
|
|
640
|
+
|
|
640
641
|
next_page_token: str | None = field(default=None, metadata={"alias": "nextPageToken"})
|
|
641
642
|
"""The token to retrieve the next page."""
|
|
642
643
|
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
+
import os
|
|
6
7
|
import re
|
|
7
8
|
from http import HTTPStatus
|
|
9
|
+
from pathlib import Path
|
|
8
10
|
from typing import Any
|
|
9
11
|
|
|
10
12
|
import aiohttp
|
|
@@ -14,6 +16,7 @@ from google_weather_api import (
|
|
|
14
16
|
GoogleWeatherApi,
|
|
15
17
|
GoogleWeatherApiAuthError,
|
|
16
18
|
GoogleWeatherApiConnectionError,
|
|
19
|
+
GoogleWeatherApiConnectionTimeoutError,
|
|
17
20
|
GoogleWeatherApiResponseError,
|
|
18
21
|
)
|
|
19
22
|
|
|
@@ -41,7 +44,8 @@ async def test_request_params_and_headers(api: GoogleWeatherApi, mock_server: Mo
|
|
|
41
44
|
await api.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
42
45
|
|
|
43
46
|
request = mock_server.requests[0]
|
|
44
|
-
assert request.
|
|
47
|
+
assert request.headers["X-Goog-Api-Key"] == API_KEY
|
|
48
|
+
assert "key" not in request.query
|
|
45
49
|
assert request.query["location.latitude"] == str(LATITUDE)
|
|
46
50
|
assert request.query["location.longitude"] == str(LONGITUDE)
|
|
47
51
|
assert request.query["language_code"] == "en"
|
|
@@ -126,7 +130,7 @@ async def test_get_hourly_forecast_follows_pagination(api: GoogleWeatherApi, moc
|
|
|
126
130
|
assert first.query["hours"] == "4"
|
|
127
131
|
assert second.query["page_token"] == page1["nextPageToken"]
|
|
128
132
|
assert second.query["hours"] == "4"
|
|
129
|
-
assert second.
|
|
133
|
+
assert second.headers["X-Goog-Api-Key"] == API_KEY
|
|
130
134
|
|
|
131
135
|
|
|
132
136
|
async def test_hourly_forecast_stops_once_enough_records(api: GoogleWeatherApi, mock_server: MockServer) -> None:
|
|
@@ -230,6 +234,32 @@ async def test_error_responses(
|
|
|
230
234
|
await api.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
231
235
|
|
|
232
236
|
|
|
237
|
+
@pytest.mark.parametrize(
|
|
238
|
+
"payload",
|
|
239
|
+
[
|
|
240
|
+
{"error": {"code": 504, "message": "Deadline expired.", "status": "DEADLINE_EXCEEDED"}},
|
|
241
|
+
"<html>504 Gateway Timeout</html>",
|
|
242
|
+
],
|
|
243
|
+
)
|
|
244
|
+
async def test_gateway_timeout_is_a_timeout_error(api: GoogleWeatherApi, mock_server: MockServer, payload: Any) -> None:
|
|
245
|
+
"""Test an HTTP 504 is reported as a timeout, like a client side timeout."""
|
|
246
|
+
mock_server.add_response(payload, status=HTTPStatus.GATEWAY_TIMEOUT)
|
|
247
|
+
|
|
248
|
+
with pytest.raises(GoogleWeatherApiConnectionTimeoutError, match="Timeout") as exc_info:
|
|
249
|
+
await api.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
250
|
+
|
|
251
|
+
assert isinstance(exc_info.value, GoogleWeatherApiConnectionError)
|
|
252
|
+
assert isinstance(exc_info.value, TimeoutError)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def test_empty_error_body(api: GoogleWeatherApi, mock_server: MockServer) -> None:
|
|
256
|
+
"""Test an error response without a JSON object is still mapped to an exception."""
|
|
257
|
+
mock_server.add_response(None, status=HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
258
|
+
|
|
259
|
+
with pytest.raises(GoogleWeatherApiResponseError, match="Unknown API error"):
|
|
260
|
+
await api.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
261
|
+
|
|
262
|
+
|
|
233
263
|
async def test_non_json_response(api: GoogleWeatherApi, mock_server: MockServer) -> None:
|
|
234
264
|
"""Test a non-JSON body is reported as a connection error."""
|
|
235
265
|
mock_server.add_response("<html>502 Bad Gateway</html>", status=HTTPStatus.BAD_GATEWAY)
|
|
@@ -238,6 +268,23 @@ async def test_non_json_response(api: GoogleWeatherApi, mock_server: MockServer)
|
|
|
238
268
|
await api.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
239
269
|
|
|
240
270
|
|
|
271
|
+
async def test_api_key_not_in_error_message(api: GoogleWeatherApi, mock_server: MockServer) -> None:
|
|
272
|
+
"""Test the API key does not leak into errors that include the request URL.
|
|
273
|
+
|
|
274
|
+
aiohttp includes the full request URL in ContentTypeError, which callers
|
|
275
|
+
commonly log, e.g. for a 502 HTML page from a proxy in front of the API.
|
|
276
|
+
"""
|
|
277
|
+
mock_server.add_response("<html>502 Bad Gateway</html>", status=HTTPStatus.BAD_GATEWAY)
|
|
278
|
+
|
|
279
|
+
with pytest.raises(GoogleWeatherApiConnectionError) as exc_info:
|
|
280
|
+
await api.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
281
|
+
|
|
282
|
+
assert "currentConditions:lookup" in str(exc_info.value)
|
|
283
|
+
assert API_KEY not in str(exc_info.value)
|
|
284
|
+
# Tracebacks render chained exceptions with str(), so the cause must be clean too.
|
|
285
|
+
assert API_KEY not in str(exc_info.value.__cause__)
|
|
286
|
+
|
|
287
|
+
|
|
241
288
|
@pytest.mark.parametrize(
|
|
242
289
|
("raised", "expected_message"),
|
|
243
290
|
[
|
|
@@ -283,3 +330,45 @@ async def test_get_minute_forecast(api: GoogleWeatherApi, mock_server: MockServe
|
|
|
283
330
|
assert request.path == "/v1/forecast/minutes:lookup"
|
|
284
331
|
assert request.query["page_size"] == "360"
|
|
285
332
|
assert "language_code" not in request.query
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _load_dotenv() -> dict[str, str]:
|
|
336
|
+
"""Load credentials from .env or environment variables."""
|
|
337
|
+
env: dict[str, str] = {}
|
|
338
|
+
env_file = Path(__file__).resolve().parent.parent / ".env"
|
|
339
|
+
if env_file.is_file():
|
|
340
|
+
for raw_line in env_file.read_text().splitlines():
|
|
341
|
+
line = raw_line.strip()
|
|
342
|
+
if line and not line.startswith("#") and "=" in line:
|
|
343
|
+
key, _, value = line.partition("=")
|
|
344
|
+
env[key.strip()] = value.strip()
|
|
345
|
+
for key in ("GOOGLE_API_KEY", "GOOGLE_REFERRER"):
|
|
346
|
+
if val := os.environ.get(key):
|
|
347
|
+
env[key] = val
|
|
348
|
+
return env
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@pytest.mark.network
|
|
352
|
+
async def test_live_weather_endpoints(session: aiohttp.ClientSession) -> None:
|
|
353
|
+
"""Test fetching live weather data from the Google Weather API using .env credentials."""
|
|
354
|
+
env = _load_dotenv()
|
|
355
|
+
api_key = env.get("GOOGLE_API_KEY")
|
|
356
|
+
if not api_key:
|
|
357
|
+
pytest.skip("GOOGLE_API_KEY not set in .env or environment")
|
|
358
|
+
client = GoogleWeatherApi(
|
|
359
|
+
session=session,
|
|
360
|
+
api_key=api_key,
|
|
361
|
+
referrer=env.get("GOOGLE_REFERRER"),
|
|
362
|
+
)
|
|
363
|
+
current = await client.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
364
|
+
assert current.time_zone.id
|
|
365
|
+
assert current.temperature.unit == "CELSIUS"
|
|
366
|
+
|
|
367
|
+
daily = await client.async_get_daily_forecast(LATITUDE, LONGITUDE, days=2)
|
|
368
|
+
assert len(daily.forecast_days) == 2
|
|
369
|
+
|
|
370
|
+
hourly = await client.async_get_hourly_forecast(LATITUDE, LONGITUDE, hours=2)
|
|
371
|
+
assert len(hourly.forecast_hours) == 2
|
|
372
|
+
|
|
373
|
+
minute = await client.async_get_minute_forecast(LATITUDE, LONGITUDE, page_size=10)
|
|
374
|
+
assert minute.time_zone.id
|
|
@@ -175,3 +175,14 @@ def test_minute_forecast_without_segments() -> None:
|
|
|
175
175
|
}
|
|
176
176
|
)
|
|
177
177
|
assert response.segments == []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_daily_and_hourly_forecasts_without_records() -> None:
|
|
181
|
+
"""Test parsing daily and hourly forecast responses when records lists are omitted."""
|
|
182
|
+
daily = DailyForecastResponse.from_dict({"timeZone": {"id": "America/New_York"}})
|
|
183
|
+
assert daily.forecast_days == []
|
|
184
|
+
assert "nextPageToken" not in daily.to_dict()
|
|
185
|
+
|
|
186
|
+
hourly = HourlyForecastResponse.from_dict({"timeZone": {"id": "America/New_York"}})
|
|
187
|
+
assert hourly.forecast_hours == []
|
|
188
|
+
assert "nextPageToken" not in hourly.to_dict()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_google_weather_api-0.0.8 → python_google_weather_api-0.0.10}/src/google_weather_api/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|