rufus-scheduler 3.3.4 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,523 +0,0 @@
1
- #--
2
- # Copyright (c) 2006-2017, John Mettraux, jmettraux@gmail.com
3
- #
4
- # Permission is hereby granted, free of charge, to any person obtaining a copy
5
- # of this software and associated documentation files (the "Software"), to deal
6
- # in the Software without restriction, including without limitation the rights
7
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
- # copies of the Software, and to permit persons to whom the Software is
9
- # furnished to do so, subject to the following conditions:
10
- #
11
- # The above copyright notice and this permission notice shall be included in
12
- # all copies or substantial portions of the Software.
13
- #
14
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
- # THE SOFTWARE.
21
- #
22
- # Made in Japan.
23
- #++
24
-
25
- require 'set'
26
-
27
-
28
- class Rufus::Scheduler
29
-
30
- #
31
- # A 'cron line' is a line in the sense of a crontab
32
- # (man 5 crontab) file line.
33
- #
34
- class CronLine
35
-
36
- # The max number of years in the future or the past before giving up
37
- # searching for #next_time or #previous_time respectively
38
- #
39
- NEXT_TIME_MAX_YEARS = 14
40
-
41
- # The string used for creating this cronline instance.
42
- #
43
- attr_reader :original
44
- attr_reader :original_timezone
45
-
46
- attr_reader :seconds
47
- attr_reader :minutes
48
- attr_reader :hours
49
- attr_reader :days
50
- attr_reader :months
51
- #attr_reader :monthdays # reader defined below
52
- attr_reader :weekdays
53
- attr_reader :timezone
54
-
55
- def initialize(line)
56
-
57
- fail ArgumentError.new(
58
- "not a string: #{line.inspect}"
59
- ) unless line.is_a?(String)
60
-
61
- @original = line
62
- @original_timezone = nil
63
-
64
- items = line.split
65
-
66
- if @timezone = ZoTime.get_tzone(items.last)
67
- @original_timezone = items.pop
68
- else
69
- @timezone = ZoTime.get_tzone(:current)
70
- end
71
-
72
- fail ArgumentError.new(
73
- "not a valid cronline : '#{line}'"
74
- ) unless items.length == 5 or items.length == 6
75
-
76
- offset = items.length - 5
77
-
78
- @seconds = offset == 1 ? parse_item(items[0], 0, 59) : [ 0 ]
79
- @minutes = parse_item(items[0 + offset], 0, 59)
80
- @hours = parse_item(items[1 + offset], 0, 24)
81
- @days = parse_item(items[2 + offset], -30, 31)
82
- @months = parse_item(items[3 + offset], 1, 12)
83
- @weekdays, @monthdays = parse_weekdays(items[4 + offset])
84
-
85
- [ @seconds, @minutes, @hours, @months ].each do |es|
86
-
87
- fail ArgumentError.new(
88
- "invalid cronline: '#{line}'"
89
- ) if es && es.find { |e| ! e.is_a?(Integer) }
90
- end
91
-
92
- if @days && @days.include?(0) # gh-221
93
-
94
- fail ArgumentError.new('invalid day 0 in cronline')
95
- end
96
- end
97
-
98
- # Returns true if the given time matches this cron line.
99
- #
100
- def matches?(time)
101
-
102
- # FIXME Don't create a new ZoTime if time is already a ZoTime in same
103
- # zone ...
104
- # Wait, this seems only used in specs...
105
- t = ZoTime.new(time.to_f, @timezone)
106
-
107
- return false unless sub_match?(t, :sec, @seconds)
108
- return false unless sub_match?(t, :min, @minutes)
109
- return false unless sub_match?(t, :hour, @hours)
110
- return false unless date_match?(t)
111
- true
112
- end
113
-
114
- # Returns the next time that this cron line is supposed to 'fire'
115
- #
116
- # This is raw, 3 secs to iterate over 1 year on my macbook :( brutal.
117
- # (Well, I was wrong, takes 0.001 sec on 1.8.7 and 1.9.1)
118
- #
119
- # This method accepts an optional Time parameter. It's the starting point
120
- # for the 'search'. By default, it's Time.now
121
- #
122
- # Note that the time instance returned will be in the same time zone that
123
- # the given start point Time (thus a result in the local time zone will
124
- # be passed if no start time is specified (search start time set to
125
- # Time.now))
126
- #
127
- # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
128
- # Time.mktime(2008, 10, 24, 7, 29))
129
- # #=> Fri Oct 24 07:30:00 -0500 2008
130
- #
131
- # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
132
- # Time.utc(2008, 10, 24, 7, 29))
133
- # #=> Fri Oct 24 07:30:00 UTC 2008
134
- #
135
- # Rufus::Scheduler::CronLine.new('30 7 * * *').next_time(
136
- # Time.utc(2008, 10, 24, 7, 29)).localtime
137
- # #=> Fri Oct 24 02:30:00 -0500 2008
138
- #
139
- # (Thanks to K Liu for the note and the examples)
140
- #
141
- def next_time(from=ZoTime.now)
142
-
143
- nt = nil
144
- zt = ZoTime.new(from.to_i + 1, @timezone)
145
- maxy = from.year + NEXT_TIME_MAX_YEARS
146
-
147
- loop do
148
-
149
- nt = zt.dup
150
-
151
- fail RangeError.new(
152
- "failed to reach occurrence within " +
153
- "#{NEXT_TIME_MAX_YEARS} years for '#{original}'"
154
- ) if nt.year > maxy
155
-
156
- unless date_match?(nt)
157
- zt.add((24 - nt.hour) * 3600 - nt.min * 60 - nt.sec)
158
- next
159
- end
160
- unless sub_match?(nt, :hour, @hours)
161
- zt.add((60 - nt.min) * 60 - nt.sec)
162
- next
163
- end
164
- unless sub_match?(nt, :min, @minutes)
165
- zt.add(60 - nt.sec)
166
- next
167
- end
168
- unless sub_match?(nt, :sec, @seconds)
169
- zt.add(next_second(nt))
170
- next
171
- end
172
-
173
- break
174
- end
175
-
176
- nt
177
- end
178
-
179
- # Returns the previous time the cronline matched. It's like next_time, but
180
- # for the past.
181
- #
182
- def previous_time(from=ZoTime.now)
183
-
184
- pt = nil
185
- zt = ZoTime.new(from.to_i - 1, @timezone)
186
- miny = from.year - NEXT_TIME_MAX_YEARS
187
-
188
- loop do
189
-
190
- pt = zt.dup
191
-
192
- fail RangeError.new(
193
- "failed to reach occurrence within " +
194
- "#{NEXT_TIME_MAX_YEARS} years for '#{original}'"
195
- ) if pt.year < miny
196
-
197
- unless date_match?(pt)
198
- zt.substract(pt.hour * 3600 + pt.min * 60 + pt.sec + 1)
199
- next
200
- end
201
- unless sub_match?(pt, :hour, @hours)
202
- zt.substract(pt.min * 60 + pt.sec + 1)
203
- next
204
- end
205
- unless sub_match?(pt, :min, @minutes)
206
- zt.substract(pt.sec + 1)
207
- next
208
- end
209
- unless sub_match?(pt, :sec, @seconds)
210
- zt.substract(prev_second(pt))
211
- next
212
- end
213
-
214
- break
215
- end
216
-
217
- pt
218
- end
219
-
220
- # Returns an array of 6 arrays (seconds, minutes, hours, days,
221
- # months, weekdays).
222
- # This method is mostly used by the cronline specs.
223
- #
224
- def to_a
225
-
226
- [
227
- toa(@seconds),
228
- toa(@minutes),
229
- toa(@hours),
230
- toa(@days),
231
- toa(@months),
232
- toa(@weekdays),
233
- toa(@monthdays),
234
- @timezone.name
235
- ]
236
- end
237
- alias to_array to_a
238
-
239
- # Returns a quickly computed approximation of the frequency for this
240
- # cron line.
241
- #
242
- # #brute_frequency, on the other hand, will compute the frequency by
243
- # examining a whole year, that can take more than seconds for a seconds
244
- # level cron...
245
- #
246
- def frequency
247
-
248
- return brute_frequency unless @seconds && @seconds.length > 1
249
-
250
- secs = toa(@seconds)
251
-
252
- secs[1..-1].inject([ secs[0], 60 ]) { |(prev, delta), sec|
253
- d = sec - prev
254
- [ sec, d < delta ? d : delta ]
255
- }[1]
256
- end
257
-
258
- # Caching facility. Currently only used for brute frequencies.
259
- #
260
- @cache = {}; class << self; attr_reader :cache; end
261
-
262
- # Returns the shortest delta between two potential occurences of the
263
- # schedule described by this cronline.
264
- #
265
- # .
266
- #
267
- # For a simple cronline like "*/5 * * * *", obviously the frequency is
268
- # five minutes. Why does this method look at a whole year of #next_time ?
269
- #
270
- # Consider "* * * * sun#2,sun#3", the computed frequency is 1 week
271
- # (the shortest delta is the one between the second sunday and the third
272
- # sunday). This method takes no chance and runs next_time for the span
273
- # of a whole year and keeps the shortest.
274
- #
275
- # Of course, this method can get VERY slow if you call on it a second-
276
- # based cronline...
277
- #
278
- def brute_frequency
279
-
280
- key = "brute_frequency:#{@original}"
281
-
282
- delta = self.class.cache[key]
283
- return delta if delta
284
-
285
- delta = 366 * DAY_S
286
-
287
- t0 = previous_time(Time.local(2000, 1, 1))
288
-
289
- loop do
290
-
291
- break if delta <= 1
292
- break if delta <= 60 && @seconds && @seconds.size == 1
293
-
294
- #st = Time.now
295
- t1 = next_time(t0)
296
- #p Time.now - st
297
- d = t1 - t0
298
- delta = d if d < delta
299
- break if @months.nil? && t1.month == 2
300
- break if @months.nil? && @days.nil? && t1.day == 2
301
- break if @months.nil? && @days.nil? && @hours.nil? && t1.hour == 1
302
- break if @months.nil? && @days.nil? && @hours.nil? && @minutes.nil? && t1.min == 1
303
- break if t1.year >= 2001
304
-
305
- t0 = t1
306
- end
307
-
308
- self.class.cache[key] = delta
309
- end
310
-
311
- def next_second(time)
312
-
313
- secs = toa(@seconds)
314
-
315
- return secs.first + 60 - time.sec if time.sec > secs.last
316
-
317
- secs.shift while secs.first < time.sec
318
-
319
- secs.first - time.sec
320
- end
321
-
322
- def prev_second(time)
323
-
324
- secs = toa(@seconds)
325
-
326
- return time.sec + 60 - secs.last if time.sec < secs.first
327
-
328
- secs.pop while time.sec < secs.last
329
-
330
- time.sec - secs.last
331
- end
332
-
333
- protected
334
-
335
- def sc_sort(a)
336
-
337
- a.sort_by { |e| e.is_a?(String) ? 61 : e.to_i }
338
- end
339
-
340
- if RUBY_VERSION >= '1.9'
341
- def toa(item)
342
- item == nil ? nil : item.to_a
343
- end
344
- else
345
- def toa(item)
346
- item.is_a?(Set) ? sc_sort(item.to_a) : item
347
- end
348
- end
349
-
350
- WEEKDAYS = %w[ sun mon tue wed thu fri sat ]
351
- DAY_S = 24 * 3600
352
-
353
- def parse_weekdays(item)
354
-
355
- return nil if item == '*'
356
-
357
- weekdays = nil
358
- monthdays = nil
359
-
360
- item.downcase.split(',').each do |it|
361
-
362
- WEEKDAYS.each_with_index { |a, i| it.gsub!(/#{a}/, i.to_s) }
363
-
364
- it = it.gsub(/([^#])l/, '\1#-1')
365
- # "5L" == "5#-1" == the last Friday
366
-
367
- if m = it.match(/\A(.+)#(l|-?[12345])\z/)
368
-
369
- fail ArgumentError.new(
370
- "ranges are not supported for monthdays (#{it})"
371
- ) if m[1].index('-')
372
-
373
- it = it.gsub(/#l/, '#-1')
374
-
375
- (monthdays ||= []) << it
376
-
377
- else
378
-
379
- fail ArgumentError.new(
380
- "invalid weekday expression (#{item})"
381
- ) if it !~ /\A0*[0-7](-0*[0-7])?\z/
382
-
383
- its = it.index('-') ? parse_range(it, 0, 7) : [ Integer(it) ]
384
- its = its.collect { |i| i == 7 ? 0 : i }
385
-
386
- (weekdays ||= []).concat(its)
387
- end
388
- end
389
-
390
- weekdays = weekdays.uniq.sort if weekdays
391
-
392
- [ weekdays, monthdays ]
393
- end
394
-
395
- def parse_item(item, min, max)
396
-
397
- return nil if item == '*'
398
-
399
- r = item.split(',').map { |i| parse_range(i.strip, min, max) }.flatten
400
-
401
- fail ArgumentError.new(
402
- "found duplicates in #{item.inspect}"
403
- ) if r.uniq.size < r.size
404
-
405
- r = sc_sort(r)
406
-
407
- Set.new(r)
408
- end
409
-
410
- RANGE_REGEX = /\A(\*|-?\d{1,2})(?:-(-?\d{1,2}))?(?:\/(\d{1,2}))?\z/
411
-
412
- def parse_range(item, min, max)
413
-
414
- return %w[ L ] if item == 'L'
415
-
416
- item = '*' + item if item[0, 1] == '/'
417
-
418
- m = item.match(RANGE_REGEX)
419
-
420
- fail ArgumentError.new(
421
- "cannot parse #{item.inspect}"
422
- ) unless m
423
-
424
- mmin = min == -30 ? 1 : min # days
425
-
426
- sta = m[1]
427
- sta = sta == '*' ? mmin : sta.to_i
428
-
429
- edn = m[2]
430
- edn = edn ? edn.to_i : sta
431
- edn = max if m[1] == '*'
432
-
433
- inc = m[3]
434
- inc = inc ? inc.to_i : 1
435
-
436
- fail ArgumentError.new(
437
- "#{item.inspect} positive/negative ranges not allowed"
438
- ) if (sta < 0 && edn > 0) || (sta > 0 && edn < 0)
439
-
440
- fail ArgumentError.new(
441
- "#{item.inspect} descending day ranges not allowed"
442
- ) if min == -30 && sta > edn
443
-
444
- fail ArgumentError.new(
445
- "#{item.inspect} is not in range #{min}..#{max}"
446
- ) if sta < min || edn > max
447
-
448
- fail ArgumentError.new(
449
- "#{item.inspect} increment must be greater than zero"
450
- ) if inc == 0
451
-
452
- r = []
453
- val = sta
454
-
455
- loop do
456
- v = val
457
- v = 0 if max == 24 && v == 24 # hours
458
- r << v
459
- break if inc == 1 && val == edn
460
- val += inc
461
- break if inc > 1 && val > edn
462
- val = min if val > max
463
- end
464
-
465
- r.uniq
466
- end
467
-
468
- # FIXME: Eventually split into day_match?, hour_match? and monthdays_match?o
469
- #
470
- def sub_match?(time, accessor, values)
471
-
472
- return true if values.nil?
473
-
474
- value = time.send(accessor)
475
-
476
- if accessor == :day
477
-
478
- values.each do |v|
479
- return true if v == 'L' && (time + DAY_S).day == 1
480
- return true if v.to_i < 0 && (time + (1 - v) * DAY_S).day == 1
481
- end
482
- end
483
-
484
- if accessor == :hour
485
-
486
- return true if value == 0 && values.include?(24)
487
- end
488
-
489
- if accessor == :monthdays
490
-
491
- return true if (values & value).any?
492
- end
493
-
494
- values.include?(value)
495
- end
496
-
497
- # def monthday_match?(zt, values)
498
- #
499
- # return true if values.nil?
500
- #
501
- # today_values = monthdays(zt)
502
- #
503
- # (today_values & values).any?
504
- # end
505
-
506
- def date_match?(zt)
507
-
508
- return false unless sub_match?(zt, :day, @days)
509
- return false unless sub_match?(zt, :month, @months)
510
-
511
- return true if (
512
- (@weekdays && @monthdays) &&
513
- (sub_match?(zt, :wday, @weekdays) ||
514
- sub_match?(zt, :monthdays, @monthdays)))
515
-
516
- return false unless sub_match?(zt, :wday, @weekdays)
517
- return false unless sub_match?(zt, :monthdays, @monthdays)
518
-
519
- true
520
- end
521
- end
522
- end
523
-