python-google-weather-api 0.0.9__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.
Files changed (17) hide show
  1. {python_google_weather_api-0.0.9/src/python_google_weather_api.egg-info → python_google_weather_api-0.0.10}/PKG-INFO +1 -1
  2. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/pyproject.toml +5 -2
  3. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/src/google_weather_api/__init__.py +2 -0
  4. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/src/google_weather_api/api.py +23 -17
  5. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/src/google_weather_api/exceptions.py +4 -0
  6. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/src/google_weather_api/model.py +7 -6
  7. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10/src/python_google_weather_api.egg-info}/PKG-INFO +1 -1
  8. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/tests/test_api.py +71 -0
  9. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/tests/test_model.py +11 -0
  10. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/LICENSE +0 -0
  11. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/README.md +0 -0
  12. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/setup.cfg +0 -0
  13. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/src/google_weather_api/py.typed +0 -0
  14. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/src/python_google_weather_api.egg-info/SOURCES.txt +0 -0
  15. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/src/python_google_weather_api.egg-info/dependency_links.txt +0 -0
  16. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.10}/src/python_google_weather_api.egg-info/requires.txt +0 -0
  17. {python_google_weather_api-0.0.9 → 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
  Metadata-Version: 2.4
2
2
  Name: python-google-weather-api
3
- Version: 0.0.9
3
+ Version: 0.0.10
4
4
  Summary: A python client library for Google Weather API
5
5
  Author-email: tronikos <tronikos@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-google-weather-api"
3
- version = "0.0.9"
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",
@@ -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 (
@@ -71,28 +72,33 @@ class GoogleWeatherApi:
71
72
  headers=headers,
72
73
  timeout=self.timeout,
73
74
  ) as resp:
74
- res: dict[str, Any] = await resp.json()
75
75
  _LOGGER.debug("Got %s for %s", resp.status, url)
76
- if resp.status != HTTPStatus.OK:
77
- error_data = res.get("error", {})
78
- error_msg = error_data.get("message", "Unknown API error")
79
-
80
- if resp.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
81
- raise GoogleWeatherApiAuthError(error_msg)
82
-
83
- # Google APIs often return 400 Bad Request for invalid API keys
84
- if resp.status == HTTPStatus.BAD_REQUEST:
85
- for detail in error_data.get("details", []):
86
- if detail.get("reason") == "API_KEY_INVALID":
87
- raise GoogleWeatherApiAuthError(error_msg)
88
-
89
- raise GoogleWeatherApiResponseError(error_msg)
90
- return res
76
+ status_code = resp.status
77
+ body = None if status_code == HTTPStatus.GATEWAY_TIMEOUT else await resp.json()
91
78
  except TimeoutError as err:
92
- raise GoogleWeatherApiConnectionError("Timeout") from err
79
+ raise GoogleWeatherApiConnectionTimeoutError("Timeout") from err
93
80
  except aiohttp.ClientError as err:
94
81
  raise GoogleWeatherApiConnectionError(err) from err
95
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
+
96
102
  async def _async_get_all_pages(
97
103
  self,
98
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
 
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-google-weather-api
3
- Version: 0.0.9
3
+ Version: 0.0.10
4
4
  Summary: A python client library for Google Weather API
5
5
  Author-email: tronikos <tronikos@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -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
 
@@ -231,6 +234,32 @@ async def test_error_responses(
231
234
  await api.async_get_current_conditions(LATITUDE, LONGITUDE)
232
235
 
233
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
+
234
263
  async def test_non_json_response(api: GoogleWeatherApi, mock_server: MockServer) -> None:
235
264
  """Test a non-JSON body is reported as a connection error."""
236
265
  mock_server.add_response("<html>502 Bad Gateway</html>", status=HTTPStatus.BAD_GATEWAY)
@@ -301,3 +330,45 @@ async def test_get_minute_forecast(api: GoogleWeatherApi, mock_server: MockServe
301
330
  assert request.path == "/v1/forecast/minutes:lookup"
302
331
  assert request.query["page_size"] == "360"
303
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()