20 |
20 |
21 A Groc schedule looks like '1st,2nd monday 9:00', or 'every 20 mins'. This |
21 A Groc schedule looks like '1st,2nd monday 9:00', or 'every 20 mins'. This |
22 module takes a parsed schedule (produced by Antlr) and creates objects that |
22 module takes a parsed schedule (produced by Antlr) and creates objects that |
23 can produce times that match this schedule. |
23 can produce times that match this schedule. |
24 |
24 |
25 A parsed schedule is one of two types - an Interval, and a Specific Time. |
25 A parsed schedule is one of two types - an Interval or a Specific Time. |
26 See the class docstrings for more. |
26 See the class docstrings for more. |
27 |
27 |
28 Extensions to be considered: |
28 Extensions to be considered: |
29 |
29 |
30 allowing a comma separated list of times to run |
30 allowing a comma separated list of times to run |
31 allowing the user to specify particular days of the month to run |
31 allowing the user to specify particular days of the month to run |
32 |
|
33 """ |
32 """ |
34 |
33 |
35 |
34 |
36 import calendar |
35 import calendar |
37 import datetime |
36 import datetime |
38 |
37 |
|
38 try: |
|
39 import pytz |
|
40 except ImportError: |
|
41 pytz = None |
|
42 |
39 import groc |
43 import groc |
40 |
44 |
41 HOURS = 'hours' |
45 HOURS = 'hours' |
42 MINUTES = 'minutes' |
46 MINUTES = 'minutes' |
|
47 |
|
48 try: |
|
49 from pytz import NonExistentTimeError |
|
50 except ImportError: |
|
51 class NonExistentTimeError(Exception): |
|
52 pass |
43 |
53 |
44 |
54 |
45 def GrocTimeSpecification(schedule): |
55 def GrocTimeSpecification(schedule): |
46 """Factory function. |
56 """Factory function. |
47 |
57 |
87 """Returns the next match after time start. |
96 """Returns the next match after time start. |
88 |
97 |
89 Must be implemented in subclasses. |
98 Must be implemented in subclasses. |
90 |
99 |
91 Arguments: |
100 Arguments: |
92 start: a datetime to start with. Matches will start from this time |
101 start: a datetime to start with. Matches will start from this time. |
93 |
102 |
94 Returns: |
103 Returns: |
95 a datetime object |
104 a datetime object |
96 """ |
105 """ |
97 raise NotImplementedError |
106 raise NotImplementedError |
98 |
107 |
99 |
108 |
100 class IntervalTimeSpecification(TimeSpecification): |
109 class IntervalTimeSpecification(TimeSpecification): |
101 """A time specification for a given interval. |
110 """A time specification for a given interval. |
102 |
111 |
103 An Interval type spec runs at the given fixed interval. They have two |
112 An Interval type spec runs at the given fixed interval. It has two |
104 attributes: |
113 attributes: |
105 period - the type of interval, either "hours" or "minutes" |
114 period - the type of interval, either "hours" or "minutes" |
106 interval - the number of units of type period. |
115 interval - the number of units of type period. |
|
116 timezone - the timezone for this specification. Not used in this class. |
107 """ |
117 """ |
108 |
118 |
109 def __init__(self, interval, period): |
119 def __init__(self, interval, period, timezone=None): |
110 super(IntervalTimeSpecification, self).__init__(self) |
120 super(IntervalTimeSpecification, self).__init__(self) |
111 self.interval = interval |
121 self.interval = interval |
112 self.period = period |
122 self.period = period |
113 |
123 |
114 def GetMatch(self, t): |
124 def GetMatch(self, t): |
115 """Returns the next match after time 't'. |
125 """Returns the next match after time 't'. |
116 |
126 |
117 Arguments: |
127 Arguments: |
118 t: a datetime to start from. Matches will start from after this time |
128 t: a datetime to start from. Matches will start from after this time. |
119 |
129 |
120 Returns: |
130 Returns: |
121 a datetime object |
131 a datetime object |
122 """ |
132 """ |
123 if self.period == HOURS: |
133 if self.period == HOURS: |
127 |
137 |
128 |
138 |
129 class SpecificTimeSpecification(TimeSpecification): |
139 class SpecificTimeSpecification(TimeSpecification): |
130 """Specific time specification. |
140 """Specific time specification. |
131 |
141 |
132 A Specific interval is more complex, but define a certain time to run, on |
142 A Specific interval is more complex, but defines a certain time to run and |
133 given days. They have the following attributes: |
143 the days that it should run. It has the following attributes: |
134 time - the time of day to run, as "HH:MM" |
144 time - the time of day to run, as "HH:MM" |
135 ordinals - first, second, third &c, as a set of integers in 1..5 |
145 ordinals - first, second, third &c, as a set of integers in 1..5 |
136 months - the months that this is valid, as a set of integers in 1..12 |
146 months - the months that this should run, as a set of integers in 1..12 |
137 weekdays - the days of the week to run this, 0=Sunday, 6=Saturday. |
147 weekdays - the days of the week that this should run, as a set of integers, |
138 |
148 0=Sunday, 6=Saturday |
139 The specific time interval can be quite complex. A schedule could look like |
149 timezone - the optional timezone as a string for this specification. |
|
150 Defaults to UTC - valid entries are things like Australia/Victoria |
|
151 or PST8PDT. |
|
152 |
|
153 A specific time schedule can be quite complex. A schedule could look like |
140 this: |
154 this: |
141 "1st,third sat,sun of jan,feb,mar 09:15" |
155 "1st,third sat,sun of jan,feb,mar 09:15" |
142 |
156 |
143 In this case, ordinals would be [1,3], weekdays [0,6], months [1,2,3] and time |
157 In this case, ordinals would be {1,3}, weekdays {0,6}, months {1,2,3} and |
144 would be "09:15". |
158 time would be "09:15". |
145 """ |
159 """ |
146 |
160 |
|
161 timezone = None |
|
162 |
147 def __init__(self, ordinals=None, weekdays=None, months=None, monthdays=None, |
163 def __init__(self, ordinals=None, weekdays=None, months=None, monthdays=None, |
148 timestr='00:00'): |
164 timestr='00:00', timezone=None): |
149 super(SpecificTimeSpecification, self).__init__(self) |
165 super(SpecificTimeSpecification, self).__init__(self) |
150 if weekdays and monthdays: |
166 if weekdays is not None and monthdays is not None: |
151 raise ValueError("can't supply both monthdays and weekdays") |
167 raise ValueError("can't supply both monthdays and weekdays") |
152 if ordinals is None: |
168 if ordinals is None: |
153 self.ordinals = set(range(1, 6)) |
169 self.ordinals = set(range(1, 6)) |
154 else: |
170 else: |
155 self.ordinals = ordinals |
171 self.ordinals = set(ordinals) |
156 |
172 |
157 if weekdays is None: |
173 if weekdays is None: |
158 self.weekdays = set(range(7)) |
174 self.weekdays = set(range(7)) |
159 else: |
175 else: |
160 self.weekdays = weekdays |
176 self.weekdays = set(weekdays) |
161 |
177 |
162 if months is None: |
178 if months is None: |
163 self.months = set(range(1, 13)) |
179 self.months = set(range(1, 13)) |
164 else: |
180 else: |
165 self.months = months |
181 self.months = set(months) |
166 |
182 |
167 if monthdays is None: |
183 if monthdays is None: |
168 self.monthdays = set() |
184 self.monthdays = set() |
169 else: |
185 else: |
170 self.monthdays = monthdays |
186 self.monthdays = set(monthdays) |
171 hourstr, minutestr = timestr.split(':') |
187 hourstr, minutestr = timestr.split(':') |
172 self.time = datetime.time(int(hourstr), int(minutestr)) |
188 self.time = datetime.time(int(hourstr), int(minutestr)) |
|
189 if timezone and pytz is not None: |
|
190 self.timezone = pytz.timezone(timezone) |
173 |
191 |
174 def _MatchingDays(self, year, month): |
192 def _MatchingDays(self, year, month): |
175 """Returns matching days for the given year and month. |
193 """Returns matching days for the given year and month. |
176 |
194 |
177 For the given year and month, return the days that match this instance's |
195 For the given year and month, return the days that match this instance's |
223 |
241 |
224 def GetMatch(self, start): |
242 def GetMatch(self, start): |
225 """Returns the next time that matches the schedule after time start. |
243 """Returns the next time that matches the schedule after time start. |
226 |
244 |
227 Arguments: |
245 Arguments: |
228 start: a datetime to start with. Matches will start after this time |
246 start: a UTC datetime to start from. Matches will start after this time |
229 |
247 |
230 Returns: |
248 Returns: |
231 a datetime object |
249 a datetime object |
232 """ |
250 """ |
233 start_time = start |
251 start_time = start |
|
252 if self.timezone and pytz is not None: |
|
253 if not start_time.tzinfo: |
|
254 start_time = pytz.utc.localize(start_time) |
|
255 start_time = start_time.astimezone(self.timezone) |
|
256 start_time = start_time.replace(tzinfo=None) |
234 if self.months: |
257 if self.months: |
235 months = self._NextMonthGenerator(start.month, self.months) |
258 months = self._NextMonthGenerator(start_time.month, self.months) |
236 while True: |
259 while True: |
237 month, yearwraps = months.next() |
260 month, yearwraps = months.next() |
238 candidate = start_time.replace(day=1, month=month, |
261 candidate_month = start_time.replace(day=1, month=month, |
239 year=start_time.year + yearwraps) |
262 year=start_time.year + yearwraps) |
240 |
263 |
241 if self.monthdays: |
264 if self.monthdays: |
242 _, last_day = calendar.monthrange(candidate.year, candidate.month) |
265 _, last_day = calendar.monthrange(candidate_month.year, |
243 day_matches = sorted([x for x in self.monthdays if x <= last_day]) |
266 candidate_month.month) |
|
267 day_matches = sorted(x for x in self.monthdays if x <= last_day) |
244 else: |
268 else: |
245 day_matches = self._MatchingDays(candidate.year, month) |
269 day_matches = self._MatchingDays(candidate_month.year, month) |
246 |
270 |
247 if ((candidate.year, candidate.month) |
271 if ((candidate_month.year, candidate_month.month) |
248 == (start_time.year, start_time.month)): |
272 == (start_time.year, start_time.month)): |
249 day_matches = [x for x in day_matches if x >= start_time.day] |
273 day_matches = [x for x in day_matches if x >= start_time.day] |
250 if day_matches and day_matches[0] == start_time.day: |
274 while (day_matches and day_matches[0] == start_time.day |
251 if start_time.time() >= self.time: |
275 and start_time.time() >= self.time): |
252 day_matches.pop(0) |
276 day_matches.pop(0) |
253 if not day_matches: |
277 while day_matches: |
254 continue |
278 out = candidate_month.replace(day=day_matches[0], hour=self.time.hour, |
255 out = candidate.replace(day=day_matches[0], hour=self.time.hour, |
279 |
256 minute=self.time.minute, second=0, microsecond=0) |
280 |
257 return out |
281 minute=self.time.minute, second=0, |
|
282 microsecond=0) |
|
283 if self.timezone and pytz is not None: |
|
284 try: |
|
285 out = self.timezone.localize(out) |
|
286 except (NonExistentTimeError, IndexError): |
|
287 for _ in range(24): |
|
288 out = out.replace(minute=1) + datetime.timedelta(minutes=60) |
|
289 try: |
|
290 out = self.timezone.localize(out) |
|
291 except (NonExistentTimeError, IndexError): |
|
292 continue |
|
293 break |
|
294 out = out.astimezone(pytz.utc) |
|
295 return out |