python-google-weather-api 0.0.7__tar.gz → 0.0.9__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.7/src/python_google_weather_api.egg-info → python_google_weather_api-0.0.9}/PKG-INFO +1 -1
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/pyproject.toml +1 -1
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/google_weather_api/__init__.py +4 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/google_weather_api/api.py +47 -10
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/google_weather_api/model.py +61 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9/src/python_google_weather_api.egg-info}/PKG-INFO +1 -1
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/tests/test_api.py +36 -2
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/tests/test_model.py +34 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/LICENSE +0 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/README.md +0 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/setup.cfg +0 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/google_weather_api/exceptions.py +0 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/google_weather_api/py.typed +0 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/python_google_weather_api.egg-info/SOURCES.txt +0 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/python_google_weather_api.egg-info/dependency_links.txt +0 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/python_google_weather_api.egg-info/requires.txt +0 -0
- {python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/python_google_weather_api.egg-info/top_level.txt +0 -0
|
@@ -21,9 +21,11 @@ from .model import (
|
|
|
21
21
|
IceThickness,
|
|
22
22
|
Interval,
|
|
23
23
|
LocalizedText,
|
|
24
|
+
MinuteForecastResponse,
|
|
24
25
|
MoonEvents,
|
|
25
26
|
Precipitation,
|
|
26
27
|
PrecipitationProbability,
|
|
28
|
+
PrecipitationSegment,
|
|
27
29
|
QuantitativePrecipitationForecast,
|
|
28
30
|
SunEvents,
|
|
29
31
|
Temperature,
|
|
@@ -54,9 +56,11 @@ __all__ = [
|
|
|
54
56
|
"IceThickness",
|
|
55
57
|
"Interval",
|
|
56
58
|
"LocalizedText",
|
|
59
|
+
"MinuteForecastResponse",
|
|
57
60
|
"MoonEvents",
|
|
58
61
|
"Precipitation",
|
|
59
62
|
"PrecipitationProbability",
|
|
63
|
+
"PrecipitationSegment",
|
|
60
64
|
"QuantitativePrecipitationForecast",
|
|
61
65
|
"SunEvents",
|
|
62
66
|
"Temperature",
|
{python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/google_weather_api/api.py
RENAMED
|
@@ -17,11 +17,14 @@ from .model import (
|
|
|
17
17
|
CurrentConditionsResponse,
|
|
18
18
|
DailyForecastResponse,
|
|
19
19
|
HourlyForecastResponse,
|
|
20
|
+
MinuteForecastResponse,
|
|
20
21
|
)
|
|
21
22
|
|
|
22
23
|
_LOGGER = logging.getLogger(__name__)
|
|
23
24
|
|
|
24
25
|
_BASE_URL = "https://weather.googleapis.com/v1"
|
|
26
|
+
# See https://docs.cloud.google.com/docs/authentication/api-keys-use
|
|
27
|
+
_API_KEY_HEADER = "X-Goog-Api-Key"
|
|
25
28
|
_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
|
|
26
29
|
|
|
27
30
|
|
|
@@ -45,20 +48,22 @@ class GoogleWeatherApi:
|
|
|
45
48
|
self.referrer = referrer
|
|
46
49
|
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
|
47
50
|
|
|
48
|
-
async def _async_get(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
async def _async_get(self, endpoint: str, params: dict[str, Any], *, include_language_code: bool = True) -> dict[str, Any]:
|
|
49
52
|
"""Perform a GET request."""
|
|
50
53
|
url = f"{_BASE_URL}/{endpoint}"
|
|
51
|
-
|
|
54
|
+
# Send the API key in a header rather than the "key" query parameter so it
|
|
55
|
+
# never ends up in a URL, which aiohttp includes in exceptions such as
|
|
56
|
+
# ContentTypeError and which callers commonly log.
|
|
57
|
+
headers = {aiohttp.hdrs.USER_AGENT: _USER_AGENT, _API_KEY_HEADER: self.api_key}
|
|
52
58
|
if self.referrer:
|
|
53
59
|
headers[aiohttp.hdrs.REFERER] = self.referrer
|
|
54
60
|
params = {
|
|
55
61
|
**params,
|
|
56
|
-
"language_code": self.language_code,
|
|
57
62
|
"units_system": self.units_system,
|
|
58
63
|
}
|
|
59
|
-
|
|
64
|
+
if include_language_code:
|
|
65
|
+
params["language_code"] = self.language_code
|
|
60
66
|
_LOGGER.debug("GET %s with params: %s", url, params)
|
|
61
|
-
params["key"] = self.api_key
|
|
62
67
|
try:
|
|
63
68
|
async with self.session.get(
|
|
64
69
|
url,
|
|
@@ -89,18 +94,27 @@ class GoogleWeatherApi:
|
|
|
89
94
|
raise GoogleWeatherApiConnectionError(err) from err
|
|
90
95
|
|
|
91
96
|
async def _async_get_all_pages(
|
|
92
|
-
self,
|
|
97
|
+
self,
|
|
98
|
+
endpoint: str,
|
|
99
|
+
params: dict[str, Any],
|
|
100
|
+
records_key: str,
|
|
101
|
+
limit: int,
|
|
102
|
+
*,
|
|
103
|
+
include_language_code: bool = True,
|
|
93
104
|
) -> dict[str, Any]:
|
|
94
105
|
"""Perform a GET request, following pagination until `limit` records are collected.
|
|
95
106
|
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
Some endpoints cap the number of records they return per page regardless of the
|
|
108
|
+
requested page size, for example 24 for hourly forecasts, so a single request is
|
|
109
|
+
not always enough.
|
|
98
110
|
"""
|
|
99
111
|
params = {**params, "page_size": limit}
|
|
100
|
-
data = await self._async_get(endpoint, params)
|
|
112
|
+
data = await self._async_get(endpoint, params, include_language_code=include_language_code)
|
|
101
113
|
records: list[Any] = data.get(records_key, [])
|
|
102
114
|
while len(records) < limit and (page_token := data.get("nextPageToken")):
|
|
103
|
-
data = await self._async_get(
|
|
115
|
+
data = await self._async_get(
|
|
116
|
+
endpoint, {**params, "page_token": page_token}, include_language_code=include_language_code
|
|
117
|
+
)
|
|
104
118
|
page_records: list[Any] = data.get(records_key, [])
|
|
105
119
|
if not page_records:
|
|
106
120
|
break
|
|
@@ -155,3 +169,26 @@ class GoogleWeatherApi:
|
|
|
155
169
|
days,
|
|
156
170
|
)
|
|
157
171
|
return DailyForecastResponse.from_dict(data)
|
|
172
|
+
|
|
173
|
+
async def async_get_minute_forecast(
|
|
174
|
+
self, latitude: float, longitude: float, page_size: int = 360
|
|
175
|
+
) -> MinuteForecastResponse:
|
|
176
|
+
"""Fetch the precipitation nowcast for up to 6 hours.
|
|
177
|
+
|
|
178
|
+
Segments cover 2- or 15-minute intervals depending on the location. The endpoint
|
|
179
|
+
does not accept language_code (a reserved field in google.maps.weather.v1), so it
|
|
180
|
+
is omitted from these requests.
|
|
181
|
+
|
|
182
|
+
See https://developers.google.com/maps/documentation/weather/minute-forecast
|
|
183
|
+
"""
|
|
184
|
+
data = await self._async_get_all_pages(
|
|
185
|
+
"forecast/minutes:lookup",
|
|
186
|
+
{
|
|
187
|
+
"location.latitude": latitude,
|
|
188
|
+
"location.longitude": longitude,
|
|
189
|
+
},
|
|
190
|
+
"segments",
|
|
191
|
+
page_size,
|
|
192
|
+
include_language_code=False,
|
|
193
|
+
)
|
|
194
|
+
return MinuteForecastResponse.from_dict(data)
|
{python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/google_weather_api/model.py
RENAMED
|
@@ -639,3 +639,64 @@ class HourlyForecastResponse(BaseModel):
|
|
|
639
639
|
|
|
640
640
|
next_page_token: str | None = field(default=None, metadata={"alias": "nextPageToken"})
|
|
641
641
|
"""The token to retrieve the next page."""
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@dataclass
|
|
645
|
+
class PrecipitationSegment(BaseModel):
|
|
646
|
+
"""Represents a segment of precipitation forecast data."""
|
|
647
|
+
|
|
648
|
+
class DominantPrecipitationType(ApiStrEnum):
|
|
649
|
+
"""Represents the type of precipitation of a segment."""
|
|
650
|
+
|
|
651
|
+
DOMINANT_PRECIPITATION_TYPE_UNSPECIFIED = "DOMINANT_PRECIPITATION_TYPE_UNSPECIFIED"
|
|
652
|
+
NONE = "NONE"
|
|
653
|
+
RAIN = "RAIN"
|
|
654
|
+
SNOW = "SNOW"
|
|
655
|
+
HAIL = "HAIL"
|
|
656
|
+
|
|
657
|
+
class PrecipitationIntensity(ApiStrEnum):
|
|
658
|
+
"""Represents the precipitation intensity of a segment."""
|
|
659
|
+
|
|
660
|
+
PRECIPITATION_INTENSITY_UNSPECIFIED = "PRECIPITATION_INTENSITY_UNSPECIFIED"
|
|
661
|
+
NO_INTENSITY = "NO_INTENSITY"
|
|
662
|
+
LIGHT = "LIGHT"
|
|
663
|
+
MID_LIGHT = "MID_LIGHT"
|
|
664
|
+
MODERATE = "MODERATE"
|
|
665
|
+
MID_MODERATE = "MID_MODERATE"
|
|
666
|
+
HEAVY = "HEAVY"
|
|
667
|
+
MID_HEAVY = "MID_HEAVY"
|
|
668
|
+
|
|
669
|
+
time_frame: Interval = field(metadata={"alias": "timeFrame"})
|
|
670
|
+
"""The timeframe relevant to the segment."""
|
|
671
|
+
|
|
672
|
+
type: DominantPrecipitationType = DominantPrecipitationType.DOMINANT_PRECIPITATION_TYPE_UNSPECIFIED
|
|
673
|
+
"""A code that indicates the type of precipitation."""
|
|
674
|
+
|
|
675
|
+
probability: int = 0
|
|
676
|
+
"""The chance of precipitation (values from 0 to 100)."""
|
|
677
|
+
|
|
678
|
+
qpf: QuantitativePrecipitationForecast | None = None
|
|
679
|
+
"""The amount of precipitation, measured as liquid water equivalent."""
|
|
680
|
+
|
|
681
|
+
snowfall_amount: QuantitativePrecipitationForecast | None = field(default=None, metadata={"alias": "snowfallAmount"})
|
|
682
|
+
"""The amount of snow accumulation, measured as liquid water equivalent."""
|
|
683
|
+
|
|
684
|
+
intensity: PrecipitationIntensity = PrecipitationIntensity.PRECIPITATION_INTENSITY_UNSPECIFIED
|
|
685
|
+
"""A code that indicates the precipitation intensity of the segment."""
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@dataclass
|
|
689
|
+
class MinuteForecastResponse(BaseModel):
|
|
690
|
+
"""Response model for the forecast.minutes.lookup method."""
|
|
691
|
+
|
|
692
|
+
overall_prediction_timeframe: Interval = field(metadata={"alias": "overallPredictionTimeframe"})
|
|
693
|
+
"""The overall timeframe for the predictions."""
|
|
694
|
+
|
|
695
|
+
time_zone: TimeZone = field(metadata={"alias": "timeZone"})
|
|
696
|
+
"""The time zone at the requested location."""
|
|
697
|
+
|
|
698
|
+
segments: list[PrecipitationSegment] = field(default_factory=list)
|
|
699
|
+
"""The minute-level prediction segments."""
|
|
700
|
+
|
|
701
|
+
next_page_token: str | None = field(default=None, metadata={"alias": "nextPageToken"})
|
|
702
|
+
"""The token to retrieve the next page."""
|
|
@@ -41,7 +41,8 @@ async def test_request_params_and_headers(api: GoogleWeatherApi, mock_server: Mo
|
|
|
41
41
|
await api.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
42
42
|
|
|
43
43
|
request = mock_server.requests[0]
|
|
44
|
-
assert request.
|
|
44
|
+
assert request.headers["X-Goog-Api-Key"] == API_KEY
|
|
45
|
+
assert "key" not in request.query
|
|
45
46
|
assert request.query["location.latitude"] == str(LATITUDE)
|
|
46
47
|
assert request.query["location.longitude"] == str(LONGITUDE)
|
|
47
48
|
assert request.query["language_code"] == "en"
|
|
@@ -126,7 +127,7 @@ async def test_get_hourly_forecast_follows_pagination(api: GoogleWeatherApi, moc
|
|
|
126
127
|
assert first.query["hours"] == "4"
|
|
127
128
|
assert second.query["page_token"] == page1["nextPageToken"]
|
|
128
129
|
assert second.query["hours"] == "4"
|
|
129
|
-
assert second.
|
|
130
|
+
assert second.headers["X-Goog-Api-Key"] == API_KEY
|
|
130
131
|
|
|
131
132
|
|
|
132
133
|
async def test_hourly_forecast_stops_once_enough_records(api: GoogleWeatherApi, mock_server: MockServer) -> None:
|
|
@@ -238,6 +239,23 @@ async def test_non_json_response(api: GoogleWeatherApi, mock_server: MockServer)
|
|
|
238
239
|
await api.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
239
240
|
|
|
240
241
|
|
|
242
|
+
async def test_api_key_not_in_error_message(api: GoogleWeatherApi, mock_server: MockServer) -> None:
|
|
243
|
+
"""Test the API key does not leak into errors that include the request URL.
|
|
244
|
+
|
|
245
|
+
aiohttp includes the full request URL in ContentTypeError, which callers
|
|
246
|
+
commonly log, e.g. for a 502 HTML page from a proxy in front of the API.
|
|
247
|
+
"""
|
|
248
|
+
mock_server.add_response("<html>502 Bad Gateway</html>", status=HTTPStatus.BAD_GATEWAY)
|
|
249
|
+
|
|
250
|
+
with pytest.raises(GoogleWeatherApiConnectionError) as exc_info:
|
|
251
|
+
await api.async_get_current_conditions(LATITUDE, LONGITUDE)
|
|
252
|
+
|
|
253
|
+
assert "currentConditions:lookup" in str(exc_info.value)
|
|
254
|
+
assert API_KEY not in str(exc_info.value)
|
|
255
|
+
# Tracebacks render chained exceptions with str(), so the cause must be clean too.
|
|
256
|
+
assert API_KEY not in str(exc_info.value.__cause__)
|
|
257
|
+
|
|
258
|
+
|
|
241
259
|
@pytest.mark.parametrize(
|
|
242
260
|
("raised", "expected_message"),
|
|
243
261
|
[
|
|
@@ -267,3 +285,19 @@ async def test_custom_timeout(session: aiohttp.ClientSession) -> None:
|
|
|
267
285
|
api = GoogleWeatherApi(session=session, api_key=API_KEY, timeout=30)
|
|
268
286
|
|
|
269
287
|
assert api.timeout.total == 30
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
async def test_get_minute_forecast(api: GoogleWeatherApi, mock_server: MockServer) -> None:
|
|
291
|
+
"""Test fetching the minute forecast, which must not send language_code."""
|
|
292
|
+
mock_server.add_response(load_fixture("minute_forecast"))
|
|
293
|
+
|
|
294
|
+
result = await api.async_get_minute_forecast(LATITUDE, LONGITUDE)
|
|
295
|
+
|
|
296
|
+
assert result.time_zone.id == "America/New_York"
|
|
297
|
+
assert result.overall_prediction_timeframe.start_time == "2026-08-11T14:17:00Z"
|
|
298
|
+
assert len(result.segments) == 2
|
|
299
|
+
assert result.segments[0].probability == 62
|
|
300
|
+
request = mock_server.requests[0]
|
|
301
|
+
assert request.path == "/v1/forecast/minutes:lookup"
|
|
302
|
+
assert request.query["page_size"] == "360"
|
|
303
|
+
assert "language_code" not in request.query
|
|
@@ -13,8 +13,10 @@ from google_weather_api import (
|
|
|
13
13
|
HourlyForecastResponse,
|
|
14
14
|
IceThickness,
|
|
15
15
|
Interval,
|
|
16
|
+
MinuteForecastResponse,
|
|
16
17
|
MoonEvents,
|
|
17
18
|
PrecipitationProbability,
|
|
19
|
+
PrecipitationSegment,
|
|
18
20
|
QuantitativePrecipitationForecast,
|
|
19
21
|
Temperature,
|
|
20
22
|
Visibility,
|
|
@@ -32,12 +34,15 @@ _RESPONSES = [
|
|
|
32
34
|
(CurrentConditionsResponse, "current_conditions"),
|
|
33
35
|
(DailyForecastResponse, "daily_forecast"),
|
|
34
36
|
(HourlyForecastResponse, "hourly_forecast_page1"),
|
|
37
|
+
(MinuteForecastResponse, "minute_forecast"),
|
|
35
38
|
]
|
|
36
39
|
|
|
37
40
|
_ENUMS = [
|
|
38
41
|
IceThickness.Unit,
|
|
39
42
|
MoonEvents.MoonPhase,
|
|
40
43
|
PrecipitationProbability.PrecipitationType,
|
|
44
|
+
PrecipitationSegment.DominantPrecipitationType,
|
|
45
|
+
PrecipitationSegment.PrecipitationIntensity,
|
|
41
46
|
QuantitativePrecipitationForecast.Unit,
|
|
42
47
|
Temperature.TemperatureUnit,
|
|
43
48
|
Visibility.Unit,
|
|
@@ -141,3 +146,32 @@ def test_next_page_token_is_optional() -> None:
|
|
|
141
146
|
data.pop("nextPageToken", None)
|
|
142
147
|
|
|
143
148
|
assert DailyForecastResponse.from_dict(data).next_page_token is None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_minute_forecast_segment_defaults() -> None:
|
|
152
|
+
"""Test parsing a minute forecast where proto3-default segment fields are omitted."""
|
|
153
|
+
response = MinuteForecastResponse.from_dict(
|
|
154
|
+
{
|
|
155
|
+
"overallPredictionTimeframe": {"startTime": "2026-08-11T14:17:00Z"},
|
|
156
|
+
"timeZone": {"id": "America/New_York"},
|
|
157
|
+
"segments": [{"timeFrame": {"startTime": "2026-08-11T14:16:00Z"}, "type": "NONE"}],
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
segment = response.segments[0]
|
|
161
|
+
assert segment.type is PrecipitationSegment.DominantPrecipitationType.NONE
|
|
162
|
+
assert segment.probability == 0
|
|
163
|
+
assert segment.qpf is None
|
|
164
|
+
assert segment.snowfall_amount is None
|
|
165
|
+
assert segment.intensity is PrecipitationSegment.PrecipitationIntensity.PRECIPITATION_INTENSITY_UNSPECIFIED
|
|
166
|
+
assert response.next_page_token is None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_minute_forecast_without_segments() -> None:
|
|
170
|
+
"""Test parsing a minute forecast response where the segments list is omitted."""
|
|
171
|
+
response = MinuteForecastResponse.from_dict(
|
|
172
|
+
{
|
|
173
|
+
"overallPredictionTimeframe": {"startTime": "2026-08-11T14:17:00Z"},
|
|
174
|
+
"timeZone": {"id": "America/New_York"},
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
assert response.segments == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_google_weather_api-0.0.7 → python_google_weather_api-0.0.9}/src/google_weather_api/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|