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.
- {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
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/pyproject.toml +5 -2
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/__init__.py +2 -0
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/api.py +41 -22
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/exceptions.py +4 -0
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/model.py +163 -41
- {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
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/tests/test_api.py +99 -0
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/tests/test_model.py +113 -0
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/LICENSE +0 -0
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/README.md +0 -0
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/setup.cfg +0 -0
- {python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/py.typed +0 -0
- {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
- {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
- {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
- {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
|
[project]
|
|
2
2
|
name = "python-google-weather-api"
|
|
3
|
-
version = "0.0.
|
|
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",
|
{python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/api.py
RENAMED
|
@@ -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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
{python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/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
|
|
@@ -92,14 +93,14 @@ class Temperature(BaseModel):
|
|
|
92
93
|
class TemperatureUnit(ApiStrEnum):
|
|
93
94
|
"""Represents a unit used to measure temperatures."""
|
|
94
95
|
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_google_weather_api-0.0.9 → python_google_weather_api-0.0.11}/src/google_weather_api/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|