Skip to content

Commit

Permalink
Retry with no data (#195)
Browse files Browse the repository at this point in the history
* retry-no-data and use in update_observation

* fix retry_no_data propagation

* enable for other updates, but make NotImplemented

* fix typing

* remove .vscode/settings.json

* better docstrings

* tests for NwsNoDataError and match statements

* add match statements to NotImplementedError in tests

* fix typo

* remove unneeded settings

* add test for raising stale forecasts
  • Loading branch information
MatthewFlamm authored May 8, 2024
1 parent ca85530 commit f050161
Show file tree
Hide file tree
Showing 5 changed files with 310 additions and 36 deletions.
3 changes: 2 additions & 1 deletion src/pynws/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
"""

from .forecast import DetailedForecast
from .nws import Nws, NwsError
from .nws import Nws, NwsError, NwsNoDataError
from .simple_nws import SimpleNWS, call_with_retry

__all__ = [
"DetailedForecast",
"Nws",
"NwsError",
"NwsNoDataError",
"SimpleNWS",
"call_with_retry",
]
4 changes: 4 additions & 0 deletions src/pynws/nws.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def __init__(self: NwsError, message: str):
self.message = message


class NwsNoDataError(NwsError):
"""No data was returned."""


class Nws:
"""Class to more easily get data for one location."""

Expand Down
119 changes: 100 additions & 19 deletions src/pynws/simple_nws.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

from .const import ALERT_ID, API_WEATHER_CODE, Final
from .forecast import DetailedForecast
from .nws import Nws, NwsError
from .nws import Nws, NwsError, NwsNoDataError
from .units import convert_unit

WIND_DIRECTIONS: Final = [
Expand All @@ -52,23 +52,48 @@
WIND: Final = {name: idx * 360 / 16 for idx, name in enumerate(WIND_DIRECTIONS)}


def _is_500_error(error: BaseException) -> bool:
"""Return True if error is ClientResponseError and has a 5xx status."""
return isinstance(error, ClientResponseError) and error.status >= 500
def _nws_retry_func(retry_no_data: bool):
"""
Return function used for tenacity.retry.
Retry if:
- if error is ClientResponseError and has a 5xx status.
- if error is NwsNoDataError, the behavior is determined by retry_no_data
Parameters
----------
retry_no_data : bool
Whether to retry when `NwsNoDataError` is raised.
"""

def _retry(error: BaseException) -> bool:
"""Whether to retry based on execptions."""
if isinstance(error, ClientResponseError) and error.status >= 500:
return True
if retry_no_data and isinstance(error, NwsNoDataError):
return True
return False

return _retry


def _setup_retry_func(
func: Callable[[Any, Any], Awaitable[Any]],
interval: Union[float, timedelta],
stop: Union[float, timedelta],
) -> Callable[[Any, Any], Awaitable[Any]]:
*,
retry_no_data=False,
) -> Callable[..., Awaitable[Any]]:
from tenacity import retry, retry_if_exception, stop_after_delay, wait_fixed

retry_func = _nws_retry_func(retry_no_data=retry_no_data)

return retry(
reraise=True,
wait=wait_fixed(interval),
stop=stop_after_delay(stop),
retry=retry_if_exception(_is_500_error),
retry=retry_if_exception(retry_func),
)(func)


Expand All @@ -78,6 +103,7 @@ async def call_with_retry(
stop: Union[float, timedelta],
/,
*args,
retry_no_data=False,
**kwargs,
) -> Callable[[Any, Any], Awaitable[Any]]:
"""Call an update function with retries.
Expand All @@ -90,13 +116,15 @@ async def call_with_retry(
Time interval for retry.
stop : float, datetime.datetime.timedelta
Time interval to stop retrying.
retry_no_data : bool
Whether to retry when no data is returned.
args : Any
Positional args to pass to func.
kwargs : Any
Keyword args to pass to func.
"""
retried_func = _setup_retry_func(func, interval, stop)
return await retried_func(*args, **kwargs)
retried_func = _setup_retry_func(func, interval, stop, retry_no_data=retry_no_data)
return await retried_func(*args, raise_no_data=retry_no_data, **kwargs)


class MetarParam(NamedTuple):
Expand Down Expand Up @@ -219,24 +247,47 @@ def extract_metar(obs: Dict[str, Any]) -> Optional[Metar.Metar]:
return metar_obs

async def update_observation(
self: SimpleNWS, limit: int = 0, start_time: Optional[datetime] = None
self: SimpleNWS,
limit: int = 0,
start_time: Optional[datetime] = None,
*,
raise_no_data: bool = False,
) -> None:
"""Update observation."""
obs = await self.get_stations_observations(limit, start_time=start_time)
if obs:
self._observation = obs
self._metar_obs = [self.extract_metar(iobs) for iobs in self._observation]
elif raise_no_data:
raise NwsNoDataError("Observation received with no data.")

async def update_forecast(self: SimpleNWS) -> None:
async def update_forecast(self: SimpleNWS, *, raise_no_data: bool = False) -> None:
"""Update forecast."""
self._forecast = await self.get_gridpoints_forecast()
if not self.forecast and raise_no_data:
raise NwsNoDataError("Forecast received with no data.")

async def update_forecast_hourly(self: SimpleNWS) -> None:
async def update_forecast_hourly(
self: SimpleNWS, *, raise_no_data: bool = False
) -> None:
"""Update forecast hourly."""
self._forecast_hourly = await self.get_gridpoints_forecast_hourly()
if not self.forecast_hourly and raise_no_data:
raise NwsNoDataError("Forecast hourly received with no data.")

async def update_detailed_forecast(
self: SimpleNWS, *, raise_no_data: bool = False
) -> None:
"""Update forecast.
Note:
`raise_no_data`currently can only be set to `False`.
"""
if raise_no_data:
raise NotImplementedError(
"raise_no_data=True not implemented for update_detailed_forecast"
)

async def update_detailed_forecast(self: SimpleNWS) -> None:
"""Update forecast."""
self._detailed_forecast = await self.get_detailed_forecast()

@staticmethod
Expand All @@ -253,22 +304,52 @@ def _new_alerts(
current_alert_ids = self._unique_alert_ids(current_alerts)
return [alert for alert in alerts if alert[ALERT_ID] not in current_alert_ids]

async def update_alerts_forecast_zone(self: SimpleNWS) -> List[Dict[str, Any]]:
"""Update alerts zone."""
async def update_alerts_forecast_zone(
self: SimpleNWS, *, raise_no_data: bool = False
) -> List[Dict[str, Any]]:
"""Update alerts zone.
Note:
`raise_no_data`currently can only be set to `False`.
"""
if raise_no_data:
raise NotImplementedError(
"raise_no_data=True not implemented for update_alerts_forecast_zone"
)
alerts = await self.get_alerts_forecast_zone()
new_alerts = self._new_alerts(alerts, self._alerts_forecast_zone)
self._alerts_forecast_zone = alerts
return new_alerts

async def update_alerts_county_zone(self: SimpleNWS) -> List[Dict[str, Any]]:
"""Update alerts zone."""
async def update_alerts_county_zone(
self: SimpleNWS, *, raise_no_data: bool = False
) -> List[Dict[str, Any]]:
"""Update alerts zone.
Note:
`raise_no_data`currently can only be set to `False`.
"""
if raise_no_data:
raise NotImplementedError(
"raise_no_data=True not implemented for update_alerts_county_zone"
)
alerts = await self.get_alerts_county_zone()
new_alerts = self._new_alerts(alerts, self._alerts_county_zone)
self._alerts_county_zone = alerts
return new_alerts

async def update_alerts_fire_weather_zone(self: SimpleNWS) -> List[Dict[str, Any]]:
"""Update alerts zone."""
async def update_alerts_fire_weather_zone(
self: SimpleNWS, *, raise_no_data: bool = False
) -> List[Dict[str, Any]]:
"""Update alerts zone.
Note:
`raise_no_data`currently can only be set to `False`.
"""
if raise_no_data:
raise NotImplementedError(
"raise_no_data=True not implemented for update_alerts_fire_weather_zone"
)
alerts = await self.get_alerts_fire_weather_zone()
new_alerts = self._new_alerts(alerts, self._alerts_fire_weather_zone)
self._alerts_fire_weather_zone = alerts
Expand Down
92 changes: 92 additions & 0 deletions tests/fixtures/gridpoints_forecast_hourly_empty.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
{
"@context": [
"https://raw.githubusercontent.com/geojson/geojson-ld/master/contexts/geojson-base.jsonld",
{
"wx": "https://api.weather.gov/ontology#",
"geo": "http://www.opengis.net/ont/geosparql#",
"unit": "http://codes.wmo.int/common/unit/",
"@vocab": "https://api.weather.gov/ontology#"
}
],
"type": "Feature",
"geometry": {
"type": "GeometryCollection",
"geometries": [
{
"type": "Point",
"coordinates": [
-84.956046999999998,
29.995017300000001
]
},
{
"type": "Polygon",
"coordinates": [
[
[
-84.968174099999999,
30.007206
],
[
-84.970116500000003,
29.984511300000001
],
[
-84.943922400000005,
29.982827500000003
],
[
-84.941974999999999,
30.005522000000003
],
[
-84.968174099999999,
30.007206
]
]
]
}
]
},
"properties": {
"updated": "2019-10-14T23:16:24+00:00",
"units": "us",
"forecastGenerator": "HourlyForecastGenerator",
"generatedAt": "2019-10-15T00:12:54+00:00",
"updateTime": "2019-10-14T23:16:24+00:00",
"validTimes": "2019-10-14T17:00:00+00:00/P7DT20H",
"elevation": {
"value": 7.9248000000000003,
"unitCode": "unit:m"
},
"periods": [
{
"number": null,
"name": null,
"startTime": null,
"endTime": null,
"isDaytime": null,
"temperature": null,
"temperatureUnit": null,
"temperatureTrend": null,
"probabilityOfPrecipitation": {
"unitCode": null,
"value": null
},
"dewpoint": {
"unitCode": null,
"value": null
},
"relativeHumidity": {
"unitCode": null,
"value": null
},
"windSpeed": null,
"windDirection": null,
"icon": null,
"shortForecast": null,
"detailedForecast": null
}
]
}
}
Loading

0 comments on commit f050161

Please sign in to comment.