Move cache location. Also, add doctests for exec_utils.
[python_utils.git] / cached / weather_forecast.py
1 #!/usr/bin/env python3
2
3 from dataclasses import dataclass
4 import datetime
5 import logging
6 import os
7 import urllib.request
8
9 import astral  # type: ignore
10 from astral.sun import sun  # type: ignore
11 from bs4 import BeautifulSoup  # type: ignore
12 import pytz
13
14 import argparse_utils
15 import config
16 import datetime_utils
17 import dateparse.dateparse_utils as dp
18 import persistent
19 import text_utils
20
21 logger = logging.getLogger(__name__)
22
23 cfg = config.add_commandline_args(
24     f'Cached Weather Forecast ({__file__})',
25     'Arguments controlling detailed weather rendering'
26 )
27 cfg.add_argument(
28     '--weather_forecast_cachefile',
29     type=str,
30     default=f'{os.environ["HOME"]}/cache/.weather_forecast_cache',
31     metavar='FILENAME',
32     help='File in which to cache weather data'
33 )
34 cfg.add_argument(
35     '--weather_forecast_stalest_acceptable',
36     type=argparse_utils.valid_duration,
37     default=datetime.timedelta(seconds=7200),   # 2 hours
38     metavar='DURATION',
39     help='Maximum acceptable age of cached data.  If zero, forces a refetch'
40 )
41
42
43 @dataclass
44 class WeatherForecast:
45     date: datetime.date                # The date
46     sunrise: datetime.datetime         # Sunrise datetime
47     sunset: datetime.datetime          # Sunset datetime
48     description: str                   # Textual description of weather
49
50
51 @persistent.persistent_autoloaded_singleton()
52 class CachedDetailedWeatherForecast(object):
53     def __init__(self, forecasts = None):
54         if forecasts is not None:
55             self.forecasts = forecasts
56             return
57
58         now = datetime_utils.now_pacific()
59         self.forecasts = {}
60
61         # Ask the raspberry pi about the outside temperature.
62         www = None
63         try:
64             www = urllib.request.urlopen(
65                 "http://10.0.0.75/~pi/outside_temp",
66                 timeout=2,
67             )
68             current_temp = www.read().decode("utf-8")
69             current_temp = float(current_temp)
70             current_temp *= (9/5)
71             current_temp += 32.0
72             current_temp = round(current_temp)
73         except Exception:
74             logger.warning('Timed out reading 10.0.0.75/~pi/outside_temp?!')
75             current_temp = None
76         finally:
77             if www is not None:
78                 www.close()
79
80         # Get a weather forecast for Bellevue.
81         www = urllib.request.urlopen(
82             "https://forecast.weather.gov/MapClick.php?lat=47.652775&lon=-122.170716"
83         )
84         forecast_response = www.read()
85         www.close()
86
87         soup = BeautifulSoup(forecast_response, "html.parser")
88         forecast = soup.find(id='detailed-forecast-body')
89         parser = dp.DateParser()
90
91         said_temp = False
92         last_dt = now
93         dt = now
94         for (day, txt) in zip(
95                 forecast.find_all('b'),
96                 forecast.find_all(class_='col-sm-10 forecast-text')
97         ):
98             last_dt = dt
99             try:
100                 dt = parser.parse(day.get_text())
101             except Exception:
102                 dt = last_dt
103             assert dt is not None
104
105             # Compute sunrise/sunset times on dt.
106             city = astral.LocationInfo(
107                 "Bellevue", "USA", "US/Pacific", 47.653, -122.171
108             )
109             s = sun(city.observer, date=dt, tzinfo=pytz.timezone("US/Pacific"))
110             sunrise = s['sunrise']
111             sunset = s['sunset']
112
113             if dt.date() == now.date() and not said_temp and current_temp is not None:
114                 blurb = f'{day.get_text()}: The current outside tempterature is {current_temp}. '
115                 blurb += txt.get_text()
116                 said_temp = True
117             else:
118                 blurb = f'{day.get_text()}: {txt.get_text()}'
119             blurb = text_utils.wrap_string(blurb, 80)
120
121             if dt.date() in self.forecasts:
122                 self.forecasts[dt.date()].description += '\n' + blurb
123             else:
124                 self.forecasts[dt.date()] = WeatherForecast(
125                     date = dt,
126                     sunrise = sunrise,
127                     sunset = sunset,
128                     description = blurb,
129                 )
130
131     @classmethod
132     def load(cls):
133         if persistent.was_file_written_within_n_seconds(
134                 config.config['weather_forecast_cachefile'],
135                 config.config['weather_forecast_stalest_acceptable'].total_seconds(),
136         ):
137             import pickle
138             with open(config.config['weather_forecast_cachefile'], 'rb') as rf:
139                 weather_data = pickle.load(rf)
140                 return cls(weather_data)
141         return None
142
143     def save(self):
144         import pickle
145         with open(config.config['weather_forecast_cachefile'], 'wb') as wf:
146             pickle.dump(
147                 self.forecasts,
148                 wf,
149                 pickle.HIGHEST_PROTOCOL,
150             )