python-google-weather-api 0.0.9__tar.gz → 0.0.11__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.11}/PKG-INFO +1 -1
  2. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/pyproject.toml +5 -2
  3. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/__init__.py +2 -0
  4. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/api.py +41 -22
  5. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/exceptions.py +4 -0
  6. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/model.py +163 -41
  7. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11/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.11}/tests/test_api.py +99 -0
  9. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/tests/test_model.py +113 -0
  10. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/LICENSE +0 -0
  11. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/README.md +0 -0
  12. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/setup.cfg +0 -0
  13. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/py.typed +0 -0
  14. {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/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.11}/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.11}/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.11}/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.11
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.11"
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",
@@ -4,16 +4,19 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  from http import HTTPStatus
7
- from typing import Any
7
+ from typing import Any, TypeVar
8
8
 
9
9
  import aiohttp
10
+ from mashumaro.exceptions import InvalidFieldValue, MissingField
10
11
 
11
12
  from .exceptions import (
12
13
  GoogleWeatherApiAuthError,
13
14
  GoogleWeatherApiConnectionError,
15
+ GoogleWeatherApiConnectionTimeoutError,
14
16
  GoogleWeatherApiResponseError,
15
17
  )
16
18
  from .model import (
19
+ BaseModel,
17
20
  CurrentConditionsResponse,
18
21
  DailyForecastResponse,
19
22
  HourlyForecastResponse,
@@ -22,6 +25,17 @@ from .model import (
22
25
 
23
26
  _LOGGER = logging.getLogger(__name__)
24
27
 
28
+ _ResponseT = TypeVar("_ResponseT", bound=BaseModel)
29
+
30
+
31
+ def _parse_response(model: type[_ResponseT], data: dict[str, Any]) -> _ResponseT:
32
+ """Deserialize an API response dictionary, wrapping schema errors."""
33
+ try:
34
+ return model.from_dict(data)
35
+ except (InvalidFieldValue, MissingField) as err:
36
+ raise GoogleWeatherApiResponseError(str(err)) from err
37
+
38
+
25
39
  _BASE_URL = "https://weather.googleapis.com/v1"
26
40
  # See https://docs.cloud.google.com/docs/authentication/api-keys-use
27
41
  _API_KEY_HEADER = "X-Goog-Api-Key"
@@ -71,28 +85,33 @@ class GoogleWeatherApi:
71
85
  headers=headers,
72
86
  timeout=self.timeout,
73
87
  ) as resp:
74
- res: dict[str, Any] = await resp.json()
75
88
  _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
89
+ status_code = resp.status
90
+ body = None if status_code == HTTPStatus.GATEWAY_TIMEOUT else await resp.json()
91
91
  except TimeoutError as err:
92
- raise GoogleWeatherApiConnectionError("Timeout") from err
92
+ raise GoogleWeatherApiConnectionTimeoutError("Timeout") from err
93
93
  except aiohttp.ClientError as err:
94
94
  raise GoogleWeatherApiConnectionError(err) from err
95
95
 
96
+ if status_code == HTTPStatus.GATEWAY_TIMEOUT:
97
+ raise GoogleWeatherApiConnectionTimeoutError("Timeout")
98
+ res: dict[str, Any] = body if isinstance(body, dict) else {}
99
+ if status_code != HTTPStatus.OK:
100
+ error_data = res.get("error", {})
101
+ error_msg = error_data.get("message", "Unknown API error")
102
+
103
+ if status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
104
+ raise GoogleWeatherApiAuthError(error_msg)
105
+
106
+ # Google APIs often return 400 Bad Request for invalid API keys
107
+ if status_code == HTTPStatus.BAD_REQUEST:
108
+ for detail in error_data.get("details", []):
109
+ if detail.get("reason") == "API_KEY_INVALID":
110
+ raise GoogleWeatherApiAuthError(error_msg)
111
+
112
+ raise GoogleWeatherApiResponseError(error_msg)
113
+ return res
114
+
96
115
  async def _async_get_all_pages(
97
116
  self,
98
117
  endpoint: str,
@@ -134,7 +153,7 @@ class GoogleWeatherApi:
134
153
  "location.longitude": longitude,
135
154
  },
136
155
  )
137
- return CurrentConditionsResponse.from_dict(data)
156
+ return _parse_response(CurrentConditionsResponse, data)
138
157
 
139
158
  async def async_get_hourly_forecast(self, latitude: float, longitude: float, hours: int = 48) -> HourlyForecastResponse:
140
159
  """Fetch hourly weather forecast.
@@ -151,7 +170,7 @@ class GoogleWeatherApi:
151
170
  "forecastHours",
152
171
  hours,
153
172
  )
154
- return HourlyForecastResponse.from_dict(data)
173
+ return _parse_response(HourlyForecastResponse, data)
155
174
 
156
175
  async def async_get_daily_forecast(self, latitude: float, longitude: float, days: int = 10) -> DailyForecastResponse:
157
176
  """Fetch daily weather forecast.
@@ -168,7 +187,7 @@ class GoogleWeatherApi:
168
187
  "forecastDays",
169
188
  days,
170
189
  )
171
- return DailyForecastResponse.from_dict(data)
190
+ return _parse_response(DailyForecastResponse, data)
172
191
 
173
192
  async def async_get_minute_forecast(
174
193
  self, latitude: float, longitude: float, page_size: int = 360
@@ -191,4 +210,4 @@ class GoogleWeatherApi:
191
210
  page_size,
192
211
  include_language_code=False,
193
212
  )
194
- return MinuteForecastResponse.from_dict(data)
213
+ return _parse_response(MinuteForecastResponse, data)
@@ -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
@@ -92,14 +93,14 @@ class Temperature(BaseModel):
92
93
  class TemperatureUnit(ApiStrEnum):
93
94
  """Represents a unit used to measure temperatures."""
94
95
 
95
- TEMPERATURE_UNIT_UNSPECIFIED = "TEMPERATURE_UNIT_UNSPECIFIED"
96
+ UNIT_UNSPECIFIED = "UNIT_UNSPECIFIED"
96
97
  CELSIUS = "CELSIUS"
97
98
  FAHRENHEIT = "FAHRENHEIT"
98
99
 
99
- degrees: float
100
+ degrees: float | None = None
100
101
  """The temperature value (in degrees) in the specified unit."""
101
102
 
102
- unit: TemperatureUnit
103
+ unit: TemperatureUnit = TemperatureUnit.UNIT_UNSPECIFIED
103
104
  """The code for the unit used to measure the temperature value."""
104
105
 
105
106
 
@@ -114,10 +115,10 @@ class QuantitativePrecipitationForecast(BaseModel):
114
115
  MILLIMETERS = "MILLIMETERS"
115
116
  INCHES = "INCHES"
116
117
 
117
- quantity: float
118
+ quantity: float | None = None
118
119
  """The amount of precipitation, measured as liquid water equivalent."""
119
120
 
120
- unit: Unit
121
+ unit: Unit = Unit.UNIT_UNSPECIFIED
121
122
  """The code of the unit used to measure the amount of accumulated precipitation."""
122
123
 
123
124
 
@@ -128,7 +129,7 @@ class PrecipitationProbability(BaseModel):
128
129
  class PrecipitationType(ApiStrEnum):
129
130
  """Represents the type of precipitation at a given location."""
130
131
 
131
- PRECIPITATION_TYPE_UNSPECIFIED = "PRECIPITATION_TYPE_UNSPECIFIED"
132
+ TYPE_UNSPECIFIED = "TYPE_UNSPECIFIED"
132
133
  NONE = "NONE"
133
134
  SNOW = "SNOW"
134
135
  RAIN = "RAIN"
@@ -137,6 +138,7 @@ class PrecipitationProbability(BaseModel):
137
138
  RAIN_AND_SNOW = "RAIN_AND_SNOW"
138
139
  SLEET = "SLEET"
139
140
  FREEZING_RAIN = "FREEZING_RAIN"
141
+ PRECIPITATION_TYPE_HAIL = "PRECIPITATION_TYPE_HAIL"
140
142
 
141
143
  type: PrecipitationType
142
144
  """A code that indicates the type of precipitation."""
@@ -166,14 +168,14 @@ class WindSpeed(BaseModel):
166
168
  class SpeedUnit(ApiStrEnum):
167
169
  """Represents the unit used to measure speed."""
168
170
 
169
- SPEED_UNIT_UNSPECIFIED = "SPEED_UNIT_UNSPECIFIED"
171
+ UNIT_UNSPECIFIED = "UNIT_UNSPECIFIED"
170
172
  KILOMETERS_PER_HOUR = "KILOMETERS_PER_HOUR"
171
173
  MILES_PER_HOUR = "MILES_PER_HOUR"
172
174
 
173
- value: float
175
+ value: float | None = None
174
176
  """The value of the wind speed."""
175
177
 
176
- unit: SpeedUnit
178
+ unit: SpeedUnit = SpeedUnit.UNIT_UNSPECIFIED
177
179
  """The code that represents the unit used to measure the wind speed."""
178
180
 
179
181
 
@@ -184,7 +186,7 @@ class WindDirection(BaseModel):
184
186
  class CardinalDirection(ApiStrEnum):
185
187
  """Represents a cardinal direction (including ordinal directions)."""
186
188
 
187
- CARDINAL_DIRECTION_UNSPECIFIED = "CARDINAL_DIRECTION_UNSPECIFIED"
189
+ CARDINAL_UNSPECIFIED = "CARDINAL_UNSPECIFIED"
188
190
  NORTH = "NORTH"
189
191
  NORTH_NORTHEAST = "NORTH_NORTHEAST"
190
192
  NORTHEAST = "NORTHEAST"
@@ -219,7 +221,7 @@ class Wind(BaseModel):
219
221
  speed: WindSpeed
220
222
  """The speed of the wind."""
221
223
 
222
- gust: WindSpeed
224
+ gust: WindSpeed | None = None
223
225
  """The wind gust (sudden increase in the wind speed)."""
224
226
 
225
227
 
@@ -234,10 +236,10 @@ class Visibility(BaseModel):
234
236
  KILOMETERS = "KILOMETERS"
235
237
  MILES = "MILES"
236
238
 
237
- distance: float
239
+ distance: float | None = None
238
240
  """The visibility distance in the specified unit."""
239
241
 
240
- unit: Unit
242
+ unit: Unit = Unit.UNIT_UNSPECIFIED
241
243
  """The code that represents the unit used to measure the distance."""
242
244
 
243
245
 
@@ -289,6 +291,120 @@ class WeatherCondition(BaseModel):
289
291
  LIGHT_THUNDERSTORM_RAIN = "LIGHT_THUNDERSTORM_RAIN"
290
292
  SCATTERED_THUNDERSTORMS = "SCATTERED_THUNDERSTORMS"
291
293
  HEAVY_THUNDERSTORM = "HEAVY_THUNDERSTORM"
294
+ CLEAR_AFTER_FOG_IN_THE_MORNING = "CLEAR_AFTER_FOG_IN_THE_MORNING"
295
+ CLEAR_BUT_LATER_TURNING_CLOUDY = "CLEAR_BUT_LATER_TURNING_CLOUDY"
296
+ CLEAR_BUT_LATER_TURNING_TO_RAIN_AND_SNOW_MAIN_CONSTITUENT_RAIN = (
297
+ "CLEAR_BUT_LATER_TURNING_TO_RAIN_AND_SNOW_MAIN_CONSTITUENT_RAIN"
298
+ )
299
+ CLEAR_BUT_LATER_TURNING_TO_SNOW_AND_RAIN_MAIN_CONSTITUENT_SNOW = (
300
+ "CLEAR_BUT_LATER_TURNING_TO_SNOW_AND_RAIN_MAIN_CONSTITUENT_SNOW"
301
+ )
302
+ CLEAR_BUT_THUNDERSTORMS_LATER = "CLEAR_BUT_THUNDERSTORMS_LATER"
303
+ CLEAR_IN_THE_MORNING_BUT_CLOUDY_IN_THE_EVENING = "CLEAR_IN_THE_MORNING_BUT_CLOUDY_IN_THE_EVENING"
304
+ CLEAR_IN_THE_MORNING_WITH_BRIEF_RAIN_IN_THE_EVENING = "CLEAR_IN_THE_MORNING_WITH_BRIEF_RAIN_IN_THE_EVENING"
305
+ CLEAR_MORNING_WITH_BRIEF_RAIN = "CLEAR_MORNING_WITH_BRIEF_RAIN"
306
+ CLEAR_WITH_BRIEF_RAIN = "CLEAR_WITH_BRIEF_RAIN"
307
+ CLEAR_WITH_BRIEF_RAIN_AND_SNOW_RAIN_AS_MAIN_CONSTITUENT = "CLEAR_WITH_BRIEF_RAIN_AND_SNOW_RAIN_AS_MAIN_CONSTITUENT"
308
+ CLEAR_WITH_BRIEF_RAIN_LATER = "CLEAR_WITH_BRIEF_RAIN_LATER"
309
+ CLEAR_WITH_BRIEF_SNOW = "CLEAR_WITH_BRIEF_SNOW"
310
+ CLEAR_WITH_BRIEF_SNOW_AND_RAIN_SNOW_AS_MAIN_CONSTITUENT = "CLEAR_WITH_BRIEF_SNOW_AND_RAIN_SNOW_AS_MAIN_CONSTITUENT"
311
+ CLEAR_WITH_BRIEF_SNOW_LATER = "CLEAR_WITH_BRIEF_SNOW_LATER"
312
+ CLEAR_WITH_PERIODIC_CLOUDS_LATER = "CLEAR_WITH_PERIODIC_CLOUDS_LATER"
313
+ CLEAR_WITH_PERIODIC_RAIN = "CLEAR_WITH_PERIODIC_RAIN"
314
+ CLEAR_WITH_PERIODIC_RAIN_ACCOMPANIED_BY_THUNDER = "CLEAR_WITH_PERIODIC_RAIN_ACCOMPANIED_BY_THUNDER"
315
+ CLEAR_WITH_PERIODIC_RAIN_AND_SNOW_RAIN_AS_MAIN_CONSTITUENT = (
316
+ "CLEAR_WITH_PERIODIC_RAIN_AND_SNOW_RAIN_AS_MAIN_CONSTITUENT"
317
+ )
318
+ CLEAR_WITH_PERIODIC_RAIN_LATER = "CLEAR_WITH_PERIODIC_RAIN_LATER"
319
+ CLEAR_WITH_PERIODIC_SNOW = "CLEAR_WITH_PERIODIC_SNOW"
320
+ CLEAR_WITH_PERIODIC_SNOW_AND_RAIN_SNOW_AS_MAIN_CONSTITUENT = (
321
+ "CLEAR_WITH_PERIODIC_SNOW_AND_RAIN_SNOW_AS_MAIN_CONSTITUENT"
322
+ )
323
+ CLEAR_WITH_PERIODIC_SNOW_LATER = "CLEAR_WITH_PERIODIC_SNOW_LATER"
324
+ CLEAR_WITH_RAIN_AFTER_MIDNIGHT = "CLEAR_WITH_RAIN_AFTER_MIDNIGHT"
325
+ CLEAR_WITH_RAIN_IN_THE_NIGHT = "CLEAR_WITH_RAIN_IN_THE_NIGHT"
326
+ CLEAR_WITH_RAIN_LATER = "CLEAR_WITH_RAIN_LATER"
327
+ CLEAR_WITH_SNOW_ALONG_THE_MOUNTAINS = "CLEAR_WITH_SNOW_ALONG_THE_MOUNTAINS"
328
+ CLEAR_WITH_SNOW_LATER = "CLEAR_WITH_SNOW_LATER"
329
+ CLEAR_WITH_THUNDERSTORMS_ALONG_THE_MOUNTAINS = "CLEAR_WITH_THUNDERSTORMS_ALONG_THE_MOUNTAINS"
330
+ CLEAR_WITH_THUNDERSTORMS_IN_THE_AFTERNOON = "CLEAR_WITH_THUNDERSTORMS_IN_THE_AFTERNOON"
331
+ CLOUDY_BUT_CLEAR_LATER = "CLOUDY_BUT_CLEAR_LATER"
332
+ CLOUDY_BUT_LATER_TURNING_TO_RAIN_AND_SNOW_MAIN_CONSTITUENT_RAIN = (
333
+ "CLOUDY_BUT_LATER_TURNING_TO_RAIN_AND_SNOW_MAIN_CONSTITUENT_RAIN"
334
+ )
335
+ CLOUDY_BUT_LATER_TURNING_TO_SNOW_AND_RAIN_MAIN_CONSTITUENT_SNOW = (
336
+ "CLOUDY_BUT_LATER_TURNING_TO_SNOW_AND_RAIN_MAIN_CONSTITUENT_SNOW"
337
+ )
338
+ CLOUDY_BUT_PERIODICALLY_CLEAR_LATER = "CLOUDY_BUT_PERIODICALLY_CLEAR_LATER"
339
+ CLOUDY_BUT_THUNDERSTORMS_LATER = "CLOUDY_BUT_THUNDERSTORMS_LATER"
340
+ CLOUDY_MORNING_WITH_BRIEF_RAIN = "CLOUDY_MORNING_WITH_BRIEF_RAIN"
341
+ CLOUDY_PERIODICALLY_CLEAR = "CLOUDY_PERIODICALLY_CLEAR"
342
+ CLOUDY_SNOWING_IN_THE_EVENING = "CLOUDY_SNOWING_IN_THE_EVENING"
343
+ CLOUDY_SNOWING_IN_THE_NIGHT = "CLOUDY_SNOWING_IN_THE_NIGHT"
344
+ CLOUDY_WITH_BRIEF_RAIN = "CLOUDY_WITH_BRIEF_RAIN"
345
+ CLOUDY_WITH_BRIEF_RAIN_AND_SNOW_RAIN_AS_MAIN_CONSTITUENT = "CLOUDY_WITH_BRIEF_RAIN_AND_SNOW_RAIN_AS_MAIN_CONSTITUENT"
346
+ CLOUDY_WITH_BRIEF_RAIN_LATER = "CLOUDY_WITH_BRIEF_RAIN_LATER"
347
+ CLOUDY_WITH_BRIEF_SNOW = "CLOUDY_WITH_BRIEF_SNOW"
348
+ CLOUDY_WITH_BRIEF_SNOW_AND_RAIN_SNOW_AS_MAIN_CONSTITUENT = "CLOUDY_WITH_BRIEF_SNOW_AND_RAIN_SNOW_AS_MAIN_CONSTITUENT"
349
+ CLOUDY_WITH_BRIEF_SNOW_LATER = "CLOUDY_WITH_BRIEF_SNOW_LATER"
350
+ CLOUDY_WITH_BRIEF_THUNDERSTORM = "CLOUDY_WITH_BRIEF_THUNDERSTORM"
351
+ CLOUDY_WITH_FOG_ON_THE_OCEAN_AND_AT_THE_SEASHORE = "CLOUDY_WITH_FOG_ON_THE_OCEAN_AND_AT_THE_SEASHORE"
352
+ CLOUDY_WITH_PERIODIC_RAIN = "CLOUDY_WITH_PERIODIC_RAIN"
353
+ CLOUDY_WITH_PERIODIC_RAIN_ACCOMPANIED_BY_THUNDER = "CLOUDY_WITH_PERIODIC_RAIN_ACCOMPANIED_BY_THUNDER"
354
+ CLOUDY_WITH_PERIODIC_RAIN_AND_SNOW_RAIN_AS_MAIN_CONSTITUENT = (
355
+ "CLOUDY_WITH_PERIODIC_RAIN_AND_SNOW_RAIN_AS_MAIN_CONSTITUENT"
356
+ )
357
+ CLOUDY_WITH_PERIODIC_RAIN_LATER = "CLOUDY_WITH_PERIODIC_RAIN_LATER"
358
+ CLOUDY_WITH_PERIODIC_SNOW = "CLOUDY_WITH_PERIODIC_SNOW"
359
+ CLOUDY_WITH_PERIODIC_SNOW_ACCOMPANIED_BY_THUNDER = "CLOUDY_WITH_PERIODIC_SNOW_ACCOMPANIED_BY_THUNDER"
360
+ CLOUDY_WITH_PERIODIC_SNOW_AND_RAIN_SNOW_AS_MAIN_CONSTITUENT = (
361
+ "CLOUDY_WITH_PERIODIC_SNOW_AND_RAIN_SNOW_AS_MAIN_CONSTITUENT"
362
+ )
363
+ CLOUDY_WITH_PERIODIC_SNOW_LATER = "CLOUDY_WITH_PERIODIC_SNOW_LATER"
364
+ CLOUDY_WITH_RAIN_AFTER_MIDNIGHT = "CLOUDY_WITH_RAIN_AFTER_MIDNIGHT"
365
+ CLOUDY_WITH_RAIN_FROM_ABOUT_NOON = "CLOUDY_WITH_RAIN_FROM_ABOUT_NOON"
366
+ CLOUDY_WITH_RAIN_FROM_EVENING = "CLOUDY_WITH_RAIN_FROM_EVENING"
367
+ CLOUDY_WITH_RAIN_LATER = "CLOUDY_WITH_RAIN_LATER"
368
+ CLOUDY_WITH_SNOW_FROM_AROUND_NOON = "CLOUDY_WITH_SNOW_FROM_AROUND_NOON"
369
+ CLOUDY_WITH_SNOW_LATER = "CLOUDY_WITH_SNOW_LATER"
370
+ FOG = "FOG"
371
+ RAINSNOW_MIX_LATER_BECOMING_CLEAR_RAIN_AS_MAIN = "RAINSNOW_MIX_LATER_BECOMING_CLEAR_RAIN_AS_MAIN"
372
+ RAINSNOW_MIX_RAIN_AS_MAIN = "RAINSNOW_MIX_RAIN_AS_MAIN"
373
+ RAINY_BUT_PERIODICALLY_CLEAR = "RAINY_BUT_PERIODICALLY_CLEAR"
374
+ RAIN_ACCOMPANIED_BY_LIGHTNING = "RAIN_ACCOMPANIED_BY_LIGHTNING"
375
+ RAIN_ACCOMPANIED_BY_WINDSTORM = "RAIN_ACCOMPANIED_BY_WINDSTORM"
376
+ RAIN_CLEAR_AT_NIGHT = "RAIN_CLEAR_AT_NIGHT"
377
+ RAIN_CLEAR_FROM_AROUND_EVENING = "RAIN_CLEAR_FROM_AROUND_EVENING"
378
+ RAIN_CLEAR_FROM_AROUND_NOON = "RAIN_CLEAR_FROM_AROUND_NOON"
379
+ RAIN_LATER_BECOMING_CLEAR = "RAIN_LATER_BECOMING_CLEAR"
380
+ RAIN_LATER_BECOMING_CLOUDY = "RAIN_LATER_BECOMING_CLOUDY"
381
+ RAIN_LATER_BECOMING_SNOW = "RAIN_LATER_BECOMING_SNOW"
382
+ RAIN_LATER_WITH_PERIODIC_SNOW = "RAIN_LATER_WITH_PERIODIC_SNOW"
383
+ RAIN_SNOW_AT_NIGHT = "RAIN_SNOW_AT_NIGHT"
384
+ RAIN_SNOW_IN_THE_EVENING = "RAIN_SNOW_IN_THE_EVENING"
385
+ RAIN_TEMPORARILY_BECOMING_SLEET = "RAIN_TEMPORARILY_BECOMING_SLEET"
386
+ RAIN_THAT_PERIODICALLY_STOPS = "RAIN_THAT_PERIODICALLY_STOPS"
387
+ RAIN_WITH_BRIEF_SNOW = "RAIN_WITH_BRIEF_SNOW"
388
+ RAIN_WITH_PERIODIC_SNOW = "RAIN_WITH_PERIODIC_SNOW"
389
+ SNOWRAIN_MIX_LATER_BECOMING_CLEAR_SNOW_AS_MAIN = "SNOWRAIN_MIX_LATER_BECOMING_CLEAR_SNOW_AS_MAIN"
390
+ SNOWRAIN_MIX_LATER_BECOMING_CLOUDY_SNOW_AS_MAIN = "SNOWRAIN_MIX_LATER_BECOMING_CLOUDY_SNOW_AS_MAIN"
391
+ SNOWRAIN_MIX_SNOW_AS_MAIN = "SNOWRAIN_MIX_SNOW_AS_MAIN"
392
+ SNOW_ACCOMPANIED_BY_LIGHTNING = "SNOW_ACCOMPANIED_BY_LIGHTNING"
393
+ SNOW_BECOMING_SLEET = "SNOW_BECOMING_SLEET"
394
+ SNOW_LATER_CLEAR = "SNOW_LATER_CLEAR"
395
+ SNOW_LATER_CLOUDY = "SNOW_LATER_CLOUDY"
396
+ SNOW_LATER_RAIN = "SNOW_LATER_RAIN"
397
+ SNOW_PERIODICALLY_CLEAR = "SNOW_PERIODICALLY_CLEAR"
398
+ SNOW_THAT_PERIODICALLY_STOPS = "SNOW_THAT_PERIODICALLY_STOPS"
399
+ SNOW_TURNING_TO_RAIN_AROUND_NOON = "SNOW_TURNING_TO_RAIN_AROUND_NOON"
400
+ SNOW_TURNING_TO_RAIN_IN_THE_EVENING = "SNOW_TURNING_TO_RAIN_IN_THE_EVENING"
401
+ SNOW_TURNING_TO_RAIN_IN_THE_NIGHT = "SNOW_TURNING_TO_RAIN_IN_THE_NIGHT"
402
+ SNOW_WITH_BRIEF_RAIN = "SNOW_WITH_BRIEF_RAIN"
403
+ SNOW_WITH_BRIEF_SLEET = "SNOW_WITH_BRIEF_SLEET"
404
+ SNOW_WITH_PERIODIC_RAIN = "SNOW_WITH_PERIODIC_RAIN"
405
+ VERY_HOT = "VERY_HOT"
406
+ SLEET = "SLEET"
407
+ HEAVY_RAIN_TO_RAINSTORM = "HEAVY_RAIN_TO_RAINSTORM"
292
408
 
293
409
  icon_base_uri: str = field(metadata={"alias": "iconBaseUri"})
294
410
  """The base URI for the icon not including the file type extension."""
@@ -311,10 +427,10 @@ class IceThickness(BaseModel):
311
427
  MILLIMETERS = "MILLIMETERS"
312
428
  INCHES = "INCHES"
313
429
 
314
- thickness: float
430
+ thickness: float | None = None
315
431
  """The ice thickness value."""
316
432
 
317
- unit: Unit
433
+ unit: Unit = Unit.UNIT_UNSPECIFIED
318
434
  """The code that represents the unit used to measure the ice thickness."""
319
435
 
320
436
 
@@ -334,6 +450,9 @@ class CurrentConditionsHistory(BaseModel):
334
450
  qpf: QuantitativePrecipitationForecast
335
451
  """The amount of precipitation (rain or snow) accumulated over the last 24 hours."""
336
452
 
453
+ snow_qpf: QuantitativePrecipitationForecast | None = field(default=None, metadata={"alias": "snowQpf"})
454
+ """The amount of snow accumulation over the last 24 hours, measured as liquid water equivalent."""
455
+
337
456
 
338
457
  @dataclass
339
458
  class CurrentConditionsResponse(BaseModel):
@@ -387,11 +506,11 @@ class CurrentConditionsResponse(BaseModel):
387
506
  uv_index: int = field(metadata={"alias": "uvIndex"})
388
507
  """The current ultraviolet (UV) index."""
389
508
 
390
- thunderstorm_probability: int = field(metadata={"alias": "thunderstormProbability"})
391
- """The current thunderstorm probability (0-100)."""
509
+ thunderstorm_probability: int | None = field(default=None, metadata={"alias": "thunderstormProbability"})
510
+ """The current thunderstorm probability (0-100). Omitted in some regions (e.g. Japan and South Korea)."""
392
511
 
393
- cloud_cover: int = field(metadata={"alias": "cloudCover"})
394
- """The current percentage of the sky covered by clouds (0-100)."""
512
+ cloud_cover: int | None = field(default=None, metadata={"alias": "cloudCover"})
513
+ """The current percentage of the sky covered by clouds (0-100). Omitted in some regions (e.g. Japan and South Korea)."""
395
514
 
396
515
 
397
516
  @dataclass
@@ -410,7 +529,7 @@ class Date(BaseModel):
410
529
 
411
530
  @dataclass
412
531
  class ForecastDayPart(BaseModel):
413
- """Represents a forecast record for a part of the day (daytime or nighttime)."""
532
+ """Represents a forecast record for a part of the day (daytime, nighttime, or full day)."""
414
533
 
415
534
  interval: Interval
416
535
  """The UTC date and time when this part of the day starts and ends."""
@@ -430,11 +549,11 @@ class ForecastDayPart(BaseModel):
430
549
  uv_index: int = field(metadata={"alias": "uvIndex"})
431
550
  """The maximum forecasted ultraviolet (UV) index."""
432
551
 
433
- thunderstorm_probability: int = field(metadata={"alias": "thunderstormProbability"})
434
- """The average thunderstorm probability."""
552
+ thunderstorm_probability: int | None = field(default=None, metadata={"alias": "thunderstormProbability"})
553
+ """The average thunderstorm probability. Omitted in some regions (e.g. Japan and South Korea)."""
435
554
 
436
- cloud_cover: int = field(metadata={"alias": "cloudCover"})
437
- """Average cloud cover percent."""
555
+ cloud_cover: int | None = field(default=None, metadata={"alias": "cloudCover"})
556
+ """Average cloud cover percent. Omitted in some regions (e.g. Japan and South Korea)."""
438
557
 
439
558
  ice_thickness: IceThickness | None = field(default=None, metadata={"alias": "iceThickness"})
440
559
  """The forecasted ice thickness."""
@@ -458,7 +577,7 @@ class MoonEvents(BaseModel):
458
577
  class MoonPhase(ApiStrEnum):
459
578
  """Marks the moon phase (a.k.a. lunar phase)."""
460
579
 
461
- MOON_PHASE_UNSPECIFIED = "MOON_PHASE_UNSPECIFIED"
580
+ PHASE_UNSPECIFIED = "PHASE_UNSPECIFIED"
462
581
  NEW_MOON = "NEW_MOON"
463
582
  WAXING_CRESCENT = "WAXING_CRESCENT"
464
583
  FIRST_QUARTER = "FIRST_QUARTER"
@@ -488,12 +607,6 @@ class ForecastDay(BaseModel):
488
607
  display_date: Date = field(metadata={"alias": "displayDate"})
489
608
  """The local date in the time zone of the location."""
490
609
 
491
- daytime_forecast: ForecastDayPart = field(metadata={"alias": "daytimeForecast"})
492
- """The forecasted weather conditions for the daytime part of the day."""
493
-
494
- nighttime_forecast: ForecastDayPart = field(metadata={"alias": "nighttimeForecast"})
495
- """The forecasted weather conditions for the nighttime part of the day."""
496
-
497
610
  max_temperature: Temperature = field(metadata={"alias": "maxTemperature"})
498
611
  """The maximum (high) temperature throughout the day."""
499
612
 
@@ -515,6 +628,15 @@ class ForecastDay(BaseModel):
515
628
  moon_events: MoonEvents = field(metadata={"alias": "moonEvents"})
516
629
  """The events related to the moon (e.g. moonrise, moonset)."""
517
630
 
631
+ daytime_forecast: ForecastDayPart | None = field(default=None, metadata={"alias": "daytimeForecast"})
632
+ """The forecasted weather conditions for the daytime part of the day."""
633
+
634
+ nighttime_forecast: ForecastDayPart | None = field(default=None, metadata={"alias": "nighttimeForecast"})
635
+ """The forecasted weather conditions for the nighttime part of the day."""
636
+
637
+ full_day_forecast: ForecastDayPart | None = field(default=None, metadata={"alias": "fullDayForecast"})
638
+ """The forecasted weather conditions for the full day (returned instead of daytime/nighttime in Japan and South Korea)."""
639
+
518
640
  ice_thickness: IceThickness | None = field(default=None, metadata={"alias": "iceThickness"})
519
641
  """The accumulated amount of ice throughout the entire day."""
520
642
 
@@ -523,12 +645,12 @@ class ForecastDay(BaseModel):
523
645
  class DailyForecastResponse(BaseModel):
524
646
  """Response model for the forecast.days.lookup method."""
525
647
 
526
- forecast_days: list[ForecastDay] = field(metadata={"alias": "forecastDays"})
527
- """The daily forecast records."""
528
-
529
648
  time_zone: TimeZone = field(metadata={"alias": "timeZone"})
530
649
  """The time zone at the requested location."""
531
650
 
651
+ forecast_days: list[ForecastDay] = field(default_factory=list, metadata={"alias": "forecastDays"})
652
+ """The daily forecast records."""
653
+
532
654
  next_page_token: str | None = field(default=None, metadata={"alias": "nextPageToken"})
533
655
  """The token to retrieve the next page."""
534
656
 
@@ -617,11 +739,11 @@ class ForecastHour(BaseModel):
617
739
  uv_index: int = field(metadata={"alias": "uvIndex"})
618
740
  """The forecasted ultraviolet (UV) index."""
619
741
 
620
- thunderstorm_probability: int = field(metadata={"alias": "thunderstormProbability"})
621
- """The forecasted thunderstorm probability (0-100)."""
742
+ thunderstorm_probability: int | None = field(default=None, metadata={"alias": "thunderstormProbability"})
743
+ """The forecasted thunderstorm probability (0-100). Omitted in some regions (e.g. Japan and South Korea)."""
622
744
 
623
- cloud_cover: int = field(metadata={"alias": "cloudCover"})
624
- """The forecasted percentage of the sky covered by clouds (0-100)."""
745
+ cloud_cover: int | None = field(default=None, metadata={"alias": "cloudCover"})
746
+ """The forecasted percentage of the sky covered by clouds (0-100). Omitted in some regions (e.g. Japan and South Korea)."""
625
747
 
626
748
  ice_thickness: IceThickness | None = field(default=None, metadata={"alias": "iceThickness"})
627
749
  """The forecasted ice thickness."""
@@ -631,12 +753,12 @@ class ForecastHour(BaseModel):
631
753
  class HourlyForecastResponse(BaseModel):
632
754
  """Response model for the forecast.hours.lookup method."""
633
755
 
634
- forecast_hours: list[ForecastHour] = field(metadata={"alias": "forecastHours"})
635
- """The hourly forecast records."""
636
-
637
756
  time_zone: TimeZone = field(metadata={"alias": "timeZone"})
638
757
  """The time zone at the requested location."""
639
758
 
759
+ forecast_hours: list[ForecastHour] = field(default_factory=list, metadata={"alias": "forecastHours"})
760
+ """The hourly forecast records."""
761
+
640
762
  next_page_token: str | None = field(default=None, metadata={"alias": "nextPageToken"})
641
763
  """The token to retrieve the next page."""
642
764
 
@@ -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.11
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
 
@@ -91,8 +94,12 @@ async def test_get_daily_forecast(api: GoogleWeatherApi, mock_server: MockServer
91
94
 
92
95
  assert len(result.forecast_days) == 2
93
96
  day = result.forecast_days[0]
97
+ assert day.max_temperature.degrees is not None
98
+ assert day.min_temperature.degrees is not None
94
99
  assert day.max_temperature.degrees >= day.min_temperature.degrees
100
+ assert day.daytime_forecast is not None
95
101
  assert day.daytime_forecast.weather_condition.type
102
+ assert day.nighttime_forecast is not None
96
103
  assert day.nighttime_forecast.weather_condition.type
97
104
  assert day.sun_events.sunrise_time
98
105
  assert day.moon_events.moon_phase
@@ -231,6 +238,32 @@ async def test_error_responses(
231
238
  await api.async_get_current_conditions(LATITUDE, LONGITUDE)
232
239
 
233
240
 
241
+ @pytest.mark.parametrize(
242
+ "payload",
243
+ [
244
+ {"error": {"code": 504, "message": "Deadline expired.", "status": "DEADLINE_EXCEEDED"}},
245
+ "<html>504 Gateway Timeout</html>",
246
+ ],
247
+ )
248
+ async def test_gateway_timeout_is_a_timeout_error(api: GoogleWeatherApi, mock_server: MockServer, payload: Any) -> None:
249
+ """Test an HTTP 504 is reported as a timeout, like a client side timeout."""
250
+ mock_server.add_response(payload, status=HTTPStatus.GATEWAY_TIMEOUT)
251
+
252
+ with pytest.raises(GoogleWeatherApiConnectionTimeoutError, match="Timeout") as exc_info:
253
+ await api.async_get_current_conditions(LATITUDE, LONGITUDE)
254
+
255
+ assert isinstance(exc_info.value, GoogleWeatherApiConnectionError)
256
+ assert isinstance(exc_info.value, TimeoutError)
257
+
258
+
259
+ async def test_empty_error_body(api: GoogleWeatherApi, mock_server: MockServer) -> None:
260
+ """Test an error response without a JSON object is still mapped to an exception."""
261
+ mock_server.add_response(None, status=HTTPStatus.INTERNAL_SERVER_ERROR)
262
+
263
+ with pytest.raises(GoogleWeatherApiResponseError, match="Unknown API error"):
264
+ await api.async_get_current_conditions(LATITUDE, LONGITUDE)
265
+
266
+
234
267
  async def test_non_json_response(api: GoogleWeatherApi, mock_server: MockServer) -> None:
235
268
  """Test a non-JSON body is reported as a connection error."""
236
269
  mock_server.add_response("<html>502 Bad Gateway</html>", status=HTTPStatus.BAD_GATEWAY)
@@ -301,3 +334,69 @@ async def test_get_minute_forecast(api: GoogleWeatherApi, mock_server: MockServe
301
334
  assert request.path == "/v1/forecast/minutes:lookup"
302
335
  assert request.query["page_size"] == "360"
303
336
  assert "language_code" not in request.query
337
+
338
+
339
+ async def test_malformed_response_raises_response_error(api: GoogleWeatherApi, mock_server: MockServer) -> None:
340
+ """Test a 200 OK response missing required schema fields raises GoogleWeatherApiResponseError."""
341
+ mock_server.add_response({})
342
+
343
+ with pytest.raises(GoogleWeatherApiResponseError):
344
+ await api.async_get_current_conditions(LATITUDE, LONGITUDE)
345
+
346
+
347
+ def _load_dotenv() -> dict[str, str]:
348
+ """Load credentials from .env or environment variables."""
349
+ env: dict[str, str] = {}
350
+ env_file = Path(__file__).resolve().parent.parent / ".env"
351
+ if env_file.is_file():
352
+ for raw_line in env_file.read_text().splitlines():
353
+ line = raw_line.strip()
354
+ if line and not line.startswith("#") and "=" in line:
355
+ key, _, value = line.partition("=")
356
+ env[key.strip()] = value.strip()
357
+ for key in ("GOOGLE_API_KEY", "GOOGLE_REFERRER"):
358
+ if val := os.environ.get(key):
359
+ env[key] = val
360
+ return env
361
+
362
+
363
+ @pytest.mark.network
364
+ async def test_live_weather_endpoints(session: aiohttp.ClientSession) -> None:
365
+ """Test fetching live weather data from the Google Weather API using .env credentials."""
366
+ env = _load_dotenv()
367
+ api_key = env.get("GOOGLE_API_KEY")
368
+ if not api_key:
369
+ pytest.skip("GOOGLE_API_KEY not set in .env or environment")
370
+ client = GoogleWeatherApi(
371
+ session=session,
372
+ api_key=api_key,
373
+ referrer=env.get("GOOGLE_REFERRER"),
374
+ )
375
+ current = await client.async_get_current_conditions(LATITUDE, LONGITUDE)
376
+ assert current.time_zone.id
377
+ assert current.temperature.unit == "CELSIUS"
378
+
379
+ daily = await client.async_get_daily_forecast(LATITUDE, LONGITUDE, days=2)
380
+ assert len(daily.forecast_days) == 2
381
+
382
+ hourly = await client.async_get_hourly_forecast(LATITUDE, LONGITUDE, hours=2)
383
+ assert len(hourly.forecast_hours) == 2
384
+
385
+ minute = await client.async_get_minute_forecast(LATITUDE, LONGITUDE, page_size=10)
386
+ assert minute.time_zone.id
387
+
388
+ # Japan (Tokyo) and South Korea (Seoul) use fullDayForecast and omit several fields.
389
+ for lat, lon, expected_tz in (
390
+ (35.6762, 139.6503, "Asia/Tokyo"),
391
+ (37.5665, 126.9780, "Asia/Seoul"),
392
+ ):
393
+ regional_current = await client.async_get_current_conditions(lat, lon)
394
+ assert regional_current.time_zone.id == expected_tz
395
+ assert regional_current.temperature.degrees is not None
396
+
397
+ regional_daily = await client.async_get_daily_forecast(lat, lon, days=2)
398
+ assert len(regional_daily.forecast_days) == 2
399
+ assert regional_daily.forecast_days[0].full_day_forecast is not None
400
+
401
+ regional_hourly = await client.async_get_hourly_forecast(lat, lon, hours=2)
402
+ assert len(regional_hourly.forecast_hours) == 2
@@ -135,6 +135,7 @@ def test_optional_fields_may_be_absent() -> None:
135
135
  assert parsed.moon_events.moonrise_times == []
136
136
  assert parsed.moon_events.moonset_times == []
137
137
  assert parsed.ice_thickness is None
138
+ assert parsed.daytime_forecast is not None
138
139
  assert parsed.daytime_forecast.ice_thickness is None
139
140
  assert parsed.daytime_forecast.precipitation.snow_qpf is None
140
141
  assert parsed.interval.end_time is None
@@ -175,3 +176,115 @@ def test_minute_forecast_without_segments() -> None:
175
176
  }
176
177
  )
177
178
  assert response.segments == []
179
+
180
+
181
+ def test_daily_and_hourly_forecasts_without_records() -> None:
182
+ """Test parsing daily and hourly forecast responses when records lists are omitted."""
183
+ daily = DailyForecastResponse.from_dict({"timeZone": {"id": "America/New_York"}})
184
+ assert daily.forecast_days == []
185
+ assert "nextPageToken" not in daily.to_dict()
186
+
187
+ hourly = HourlyForecastResponse.from_dict({"timeZone": {"id": "America/New_York"}})
188
+ assert hourly.forecast_hours == []
189
+ assert "nextPageToken" not in hourly.to_dict()
190
+
191
+
192
+ def test_japan_and_south_korea_current_conditions(caplog: pytest.LogCaptureFixture) -> None:
193
+ """Test parsing current conditions with Japan/South Korea omitted and UNIT_UNSPECIFIED fields."""
194
+ data = load_fixture("current_conditions")
195
+ data["timeZone"] = {"id": "Asia/Tokyo", "version": ""}
196
+ data["heatIndex"] = {"unit": "UNIT_UNSPECIFIED"}
197
+ data["windChill"] = {"unit": "UNIT_UNSPECIFIED"}
198
+ data["visibility"] = {"unit": "UNIT_UNSPECIFIED"}
199
+ data["precipitation"]["snowQpf"] = {"unit": "UNIT_UNSPECIFIED"}
200
+ data["currentConditionsHistory"]["snowQpf"] = {"unit": "UNIT_UNSPECIFIED"}
201
+ data["wind"].pop("gust", None)
202
+ data.pop("cloudCover", None)
203
+ data.pop("thunderstormProbability", None)
204
+
205
+ with caplog.at_level(logging.WARNING, logger="google_weather_api"):
206
+ parsed = CurrentConditionsResponse.from_dict(data)
207
+
208
+ assert caplog.records == []
209
+ assert parsed.heat_index.degrees is None
210
+ assert parsed.heat_index.unit is Temperature.TemperatureUnit.UNIT_UNSPECIFIED
211
+ assert parsed.wind_chill.degrees is None
212
+ assert parsed.visibility.distance is None
213
+ assert parsed.visibility.unit is Visibility.Unit.UNIT_UNSPECIFIED
214
+ assert parsed.precipitation.snow_qpf is not None
215
+ assert parsed.precipitation.snow_qpf.quantity is None
216
+ assert parsed.current_conditions_history.snow_qpf is not None
217
+ assert parsed.current_conditions_history.snow_qpf.quantity is None
218
+ assert parsed.wind.gust is None
219
+ assert parsed.cloud_cover is None
220
+ assert parsed.thunderstorm_probability is None
221
+ assert CurrentConditionsResponse.from_dict(parsed.to_dict()) == parsed
222
+
223
+
224
+ def test_japan_and_south_korea_daily_forecast(caplog: pytest.LogCaptureFixture) -> None:
225
+ """Test parsing Japan/South Korea fullDayForecast, regional condition types, and omitted fields."""
226
+ daily_data = load_fixture("daily_forecast")
227
+ day: dict[str, Any] = daily_data["forecastDays"][0]
228
+ full_day = day.pop("daytimeForecast")
229
+ day.pop("nighttimeForecast")
230
+ full_day["weatherCondition"]["type"] = "CLOUDY_PERIODICALLY_CLEAR"
231
+ full_day["wind"].pop("gust", None)
232
+ full_day.pop("cloudCover", None)
233
+ full_day.pop("thunderstormProbability", None)
234
+ full_day["iceThickness"] = {"unit": "UNIT_UNSPECIFIED"}
235
+ full_day["precipitation"]["snowQpf"] = {"unit": "UNIT_UNSPECIFIED"}
236
+ day["fullDayForecast"] = full_day
237
+ day["feelsLikeMaxTemperature"] = {"unit": "UNIT_UNSPECIFIED"}
238
+ day["feelsLikeMinTemperature"] = {"unit": "UNIT_UNSPECIFIED"}
239
+ day["maxHeatIndex"] = {"unit": "UNIT_UNSPECIFIED"}
240
+ day.pop("iceThickness", None)
241
+
242
+ with caplog.at_level(logging.WARNING, logger="google_weather_api"):
243
+ parsed_daily = DailyForecastResponse.from_dict(daily_data)
244
+
245
+ assert caplog.records == []
246
+ parsed_day = parsed_daily.forecast_days[0]
247
+ assert parsed_day.daytime_forecast is None
248
+ assert parsed_day.nighttime_forecast is None
249
+ assert parsed_day.full_day_forecast is not None
250
+ assert parsed_day.full_day_forecast.weather_condition.type is WeatherCondition.Type.CLOUDY_PERIODICALLY_CLEAR
251
+ assert parsed_day.full_day_forecast.wind.gust is None
252
+ assert parsed_day.full_day_forecast.cloud_cover is None
253
+ assert parsed_day.full_day_forecast.thunderstorm_probability is None
254
+ assert parsed_day.full_day_forecast.ice_thickness is not None
255
+ assert parsed_day.full_day_forecast.ice_thickness.thickness is None
256
+ assert parsed_day.feels_like_max_temperature.degrees is None
257
+ assert parsed_day.feels_like_min_temperature.degrees is None
258
+ assert parsed_day.max_heat_index.degrees is None
259
+ assert DailyForecastResponse.from_dict(parsed_daily.to_dict()) == parsed_daily
260
+ assert len(WeatherCondition.Type) == 139
261
+
262
+
263
+ def test_japan_and_south_korea_hourly_forecast(caplog: pytest.LogCaptureFixture) -> None:
264
+ """Test parsing Japan/South Korea hourly forecast with omitted and UNIT_UNSPECIFIED fields."""
265
+ hourly_data = load_fixture("hourly_forecast_page1")
266
+ hour: dict[str, Any] = hourly_data["forecastHours"][0]
267
+ hour["heatIndex"] = {"unit": "UNIT_UNSPECIFIED"}
268
+ hour["windChill"] = {"unit": "UNIT_UNSPECIFIED"}
269
+ hour["wetBulbTemperature"] = {"unit": "UNIT_UNSPECIFIED"}
270
+ hour["visibility"] = {"unit": "UNIT_UNSPECIFIED"}
271
+ hour["iceThickness"] = {"unit": "UNIT_UNSPECIFIED"}
272
+ hour["wind"].pop("gust", None)
273
+ hour.pop("cloudCover", None)
274
+ hour.pop("thunderstormProbability", None)
275
+
276
+ with caplog.at_level(logging.WARNING, logger="google_weather_api"):
277
+ parsed_hourly = HourlyForecastResponse.from_dict(hourly_data)
278
+
279
+ assert caplog.records == []
280
+ parsed_hour = parsed_hourly.forecast_hours[0]
281
+ assert parsed_hour.heat_index.degrees is None
282
+ assert parsed_hour.wind_chill.degrees is None
283
+ assert parsed_hour.wet_bulb_temperature.degrees is None
284
+ assert parsed_hour.visibility.distance is None
285
+ assert parsed_hour.ice_thickness is not None
286
+ assert parsed_hour.ice_thickness.thickness is None
287
+ assert parsed_hour.wind.gust is None
288
+ assert parsed_hour.cloud_cover is None
289
+ assert parsed_hour.thunderstorm_probability is None
290
+ assert HourlyForecastResponse.from_dict(parsed_hourly.to_dict()) == parsed_hourly