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