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