parse_cron 0.1.6

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 750f532c412645b248e8c4a54b9f627e991df28dfe3c0558f95987dda19e0e5a
4
+ data.tar.gz: b904258d25a80df069a2b93ddf7d80a4f8d0539bd4bde995fa95acc8be064805
5
+ SHA512:
6
+ metadata.gz: acc56afbfa764865546af83b89441b99f8becb10543c27d57c214814b6986784175b84ad581cf1500f81c214fdeceba18d097d93a07072bf153a76c413a2a182
7
+ data.tar.gz: 95d8feca6e3bc6884ab536a8c130f9ae79c7ca46e34b4e3b509c11e2d28c6c0b0c179944628ccca33cdeb5624d03b45776621351cc8dafd22cfb332773e6ebf8
@@ -0,0 +1,6 @@
1
+ *.gem
2
+ *.swp
3
+ .bundle
4
+ Gemfile.lock
5
+ pkg/*
6
+ .rvmrc
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - "2.1.8"
4
+ - "2.2.4"
5
+ - "2.3.0"
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in parse_cron.gemspec
4
+ gemspec
5
+
6
+ gem "ZenTest", "4.6.0"
7
+ gem "rake"
data/License ADDED
@@ -0,0 +1,21 @@
1
+ Copyright (C) 2019 Oscar Mendoza <chi23bears@gmail.com>
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated documentation
5
+ files (the "Software"), to deal in the Software without restriction,
6
+ including without limitation the rights to use, copy, modify,
7
+ merge, publish, distribute, sublicense, and/or sell copies of
8
+ 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
12
+ be included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
16
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
18
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
19
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
21
+ OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,17 @@
1
+ # Parse_Cron
2
+ ## Parse crontab syntax to determine scheduled run times [![Build Status](https://travis-ci.org/siebertm/parse_cron.png)](https://travis-ci.org/siebertm/parse_cron)
3
+
4
+ The goal of this gem is to parse a crontab timing specification and determine when the
5
+ job should be run. It is not a scheduler, it does not run the jobs.
6
+
7
+ ## API example
8
+
9
+ ```
10
+ cron_parser = CronParser.new('30 * * * *')
11
+
12
+ # Next occurrence
13
+ next_time = cron_parser.next(Time.now)
14
+
15
+ # Last occurrence
16
+ most_recent_time = cron_parser.last(Time.now)
17
+ ```
@@ -0,0 +1,11 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ RSpec::Core::RakeTask.new
6
+ task :default => :spec
7
+
8
+ desc 'Start IRB with preloaded environment'
9
+ task :console do
10
+ exec 'irb', "-I#{File.join(File.dirname(__FILE__), 'lib')}", '-rparse_cron'
11
+ end
@@ -0,0 +1,362 @@
1
+ require 'set'
2
+ require 'date'
3
+
4
+ # Parses cron expressions and computes the next occurence of the "job"
5
+ #
6
+ class CronParser
7
+ # internal "mutable" time representation
8
+ class InternalTime
9
+ attr_accessor :year, :month, :day, :hour, :min, :sec
10
+ attr_accessor :time_source
11
+
12
+ def initialize(time = Time.now, time_source = Time)
13
+ @year = time.year
14
+ @month = time.month
15
+ @day = time.day
16
+ @hour = time.hour
17
+ @min = time.min
18
+ @sec = time.sec
19
+
20
+ @time_source = time_source
21
+ end
22
+
23
+ def to_time
24
+ time_source.local(@year, @month, @day, @hour, @min, @sec)
25
+ end
26
+
27
+ def inspect
28
+ [year, month, day, hour, min, sec].inspect
29
+ end
30
+ end
31
+
32
+ SYMBOLS = {
33
+ "jan" => "1",
34
+ "feb" => "2",
35
+ "mar" => "3",
36
+ "apr" => "4",
37
+ "may" => "5",
38
+ "jun" => "6",
39
+ "jul" => "7",
40
+ "aug" => "8",
41
+ "sep" => "9",
42
+ "oct" => "10",
43
+ "nov" => "11",
44
+ "dec" => "12",
45
+
46
+ "sun" => "0",
47
+ "mon" => "1",
48
+ "tue" => "2",
49
+ "wed" => "3",
50
+ "thu" => "4",
51
+ "fri" => "5",
52
+ "sat" => "6"
53
+ }
54
+
55
+ def initialize(source,time_source = Time)
56
+ @source = interpret_vixieisms(source)
57
+ @time_source = time_source
58
+ validate_source
59
+ end
60
+
61
+ def interpret_vixieisms(spec)
62
+ case spec
63
+ when '@reboot'
64
+ raise ArgumentError, "Can't predict last/next run of @reboot"
65
+ when '@yearly', '@annually'
66
+ '0 0 1 1 *'
67
+ when '@monthly'
68
+ '0 0 1 * *'
69
+ when '@weekly'
70
+ '0 0 * * 0'
71
+ when '@daily', '@midnight'
72
+ '0 0 * * *'
73
+ when '@hourly'
74
+ '0 * * * *'
75
+ when '@minutely'
76
+ '* * * * *'
77
+ else
78
+ spec
79
+ end
80
+ end
81
+
82
+
83
+ # returns the next occurence after the given date
84
+ def next(now = @time_source.now, num = 1)
85
+ t = InternalTime.new(now, @time_source)
86
+
87
+ unless time_specs[:year][0].include?(t.year)
88
+ nudge_year(t)
89
+ t.month = 0
90
+ end
91
+
92
+ unless time_specs[:month][0].include?(t.month)
93
+ nudge_month(t)
94
+ t.day = 0
95
+ end
96
+
97
+ unless interpolate_weekdays(t.year, t.month)[0].include?(t.day)
98
+ nudge_date(t)
99
+ t.hour = -1
100
+ end
101
+
102
+ unless time_specs[:hour][0].include?(t.hour)
103
+ nudge_hour(t)
104
+ t.min = -1
105
+ end
106
+
107
+ unless time_specs[:minute][0].include?(t.min)
108
+ nudge_minute(t)
109
+ t.sec = -1
110
+ end
111
+
112
+ # always nudge the second
113
+ nudge_second(t)
114
+ t = t.to_time
115
+ if num > 1
116
+ recursive_calculate(:next,t,num)
117
+ else
118
+ t
119
+ end
120
+ end
121
+
122
+ # returns the last occurence before the given date
123
+ def last(now = @time_source.now, num=1)
124
+ t = InternalTime.new(now,@time_source)
125
+
126
+ unless time_specs[:year][0].include?(t.year)
127
+ nudge_year(t, :last)
128
+ t.month = 13
129
+ end
130
+
131
+ unless time_specs[:month][0].include?(t.month)
132
+ nudge_month(t, :last)
133
+ t.day = 32
134
+ end
135
+
136
+ if t.day == 32 || !interpolate_weekdays(t.year, t.month)[0].include?(t.day)
137
+ nudge_date(t, :last)
138
+ t.hour = 24
139
+ end
140
+
141
+ unless time_specs[:hour][0].include?(t.hour)
142
+ nudge_hour(t, :last)
143
+ t.min = 60
144
+ end
145
+
146
+ unless time_specs[:minute][0].include?(t.min)
147
+ nudge_minute(t, :last)
148
+ t.sec = 60
149
+ end
150
+
151
+ # always nudge the second
152
+ nudge_second(t, :last)
153
+ t = t.to_time
154
+ if num > 1
155
+ recursive_calculate(:last,t,num)
156
+ else
157
+ t
158
+ end
159
+ end
160
+
161
+
162
+ SUBELEMENT_REGEX = %r{^(\d+)(-(\d+)(/(\d+))?)?$}
163
+ def parse_element(elem, allowed_range)
164
+ values = elem.split(',').map do |subel|
165
+ if subel =~ /^\*/
166
+ step = subel.length > 1 ? subel[2..-1].to_i : 1
167
+ stepped_range(allowed_range, step)
168
+ elsif subel =~ /^\?$/ && (allowed_range == (1..31) || allowed_range == (0..6))
169
+ step = subel.length > 1 ? subel[2..-1].to_i : 1
170
+ stepped_range(allowed_range, step)
171
+ else
172
+ if SUBELEMENT_REGEX === subel
173
+ if $5 # with range
174
+ stepped_range($1.to_i..$3.to_i, $5.to_i)
175
+ elsif $3 # range without step
176
+ stepped_range($1.to_i..$3.to_i, 1)
177
+ else # just a numeric
178
+ [$1.to_i]
179
+ end
180
+ else
181
+ raise ArgumentError, "Bad Vixie-style specification #{subel}"
182
+ end
183
+ end
184
+ end.flatten.sort
185
+
186
+ [Set.new(values), values, elem]
187
+ end
188
+
189
+
190
+ protected
191
+
192
+ def recursive_calculate(meth,time,num)
193
+ array = [time]
194
+ num.-(1).times do |num|
195
+ array << self.send(meth, array.last)
196
+ end
197
+ array
198
+ end
199
+
200
+ # returns a list of days which do both match time_spec[:dom] or time_spec[:dow]
201
+ def interpolate_weekdays(year, month)
202
+ @_interpolate_weekdays_cache ||= {}
203
+ @_interpolate_weekdays_cache["#{year}-#{month}"] ||= interpolate_weekdays_without_cache(year, month)
204
+ end
205
+
206
+ def interpolate_weekdays_without_cache(year, month)
207
+ t = Date.new(year, month, 1)
208
+ valid_mday, _, mday_field = time_specs[:dom]
209
+ valid_wday, _, wday_field = time_specs[:dow]
210
+
211
+ # Careful, if both DOW and DOM fields are non-wildcard,
212
+ # then we only need to match *one* for cron to run the job:
213
+ if not (mday_field == '*' and wday_field == '*')
214
+ valid_mday = [] if mday_field == '*'
215
+ valid_wday = [] if wday_field == '*'
216
+ end
217
+ # Careful: crontabs may use either 0 or 7 for Sunday:
218
+ valid_wday << 0 if valid_wday.include?(7)
219
+
220
+ result = []
221
+ while t.month == month
222
+ result << t.mday if valid_mday.include?(t.mday) || valid_wday.include?(t.wday)
223
+ t = t.succ
224
+ end
225
+
226
+ [Set.new(result), result]
227
+ end
228
+
229
+ def nudge_year(t, dir = :next)
230
+ spec = time_specs[:year][1]
231
+ next_value = find_best_next(t.year, spec, dir)
232
+ t.year = next_value || (dir == :next ? spec.first : spec.last)
233
+
234
+ # We've exhausted all years in the range
235
+ raise "No matching dates exist" if next_value.nil?
236
+ end
237
+
238
+ def nudge_month(t, dir = :next)
239
+ spec = time_specs[:month][1]
240
+ next_value = find_best_next(t.month, spec, dir)
241
+ t.month = next_value || (dir == :next ? spec.first : spec.last)
242
+
243
+ nudge_year(t, dir) if next_value.nil?
244
+
245
+ # we changed the month, so its likely that the date is incorrect now
246
+ valid_days = interpolate_weekdays(t.year, t.month)[1]
247
+ t.day = dir == :next ? valid_days.first : valid_days.last
248
+ end
249
+
250
+ def date_valid?(t, dir = :next)
251
+ interpolate_weekdays(t.year, t.month)[0].include?(t.day)
252
+ end
253
+
254
+ def nudge_date(t, dir = :next, can_nudge_month = true)
255
+ spec = interpolate_weekdays(t.year, t.month)[1]
256
+ next_value = find_best_next(t.day, spec, dir)
257
+ t.day = next_value || (dir == :next ? spec.first : spec.last)
258
+
259
+ nudge_month(t, dir) if next_value.nil? && can_nudge_month
260
+ end
261
+
262
+ def nudge_hour(t, dir = :next)
263
+ spec = time_specs[:hour][1]
264
+ next_value = find_best_next(t.hour, spec, dir)
265
+ t.hour = next_value || (dir == :next ? spec.first : spec.last)
266
+
267
+ nudge_date(t, dir) if next_value.nil?
268
+ end
269
+
270
+ def nudge_minute(t, dir = :next)
271
+ spec = time_specs[:minute][1]
272
+ next_value = find_best_next(t.min, spec, dir)
273
+ t.min = next_value || (dir == :next ? spec.first : spec.last)
274
+
275
+ nudge_hour(t, dir) if next_value.nil?
276
+ end
277
+
278
+ def nudge_second(t, dir = :next)
279
+ spec = time_specs[:second][1]
280
+ next_value = find_best_next(t.sec, spec, dir)
281
+ t.sec = next_value || (dir == :next ? spec.first : spec.last)
282
+
283
+ nudge_minute(t, dir) if next_value.nil?
284
+ end
285
+
286
+ def time_specs
287
+ @time_specs ||= begin
288
+ tokens = substitute_parse_symbols(@source).split(/\s+/)
289
+ # tokens now contains the 5 or 7 fields
290
+
291
+ if tokens.count == 5
292
+ {
293
+ :second => parse_element("0", 0..59), #second
294
+ :minute => parse_element(tokens[0], 0..59), #minute
295
+ :hour => parse_element(tokens[1], 0..23), #hour
296
+ :dom => parse_element(tokens[2], 1..31), #DOM
297
+ :month => parse_element(tokens[3], 1..12), #mon
298
+ :dow => parse_element(tokens[4], 0..6), #DOW
299
+ :year => parse_element("*", 2000..2050) #year
300
+ }
301
+ elsif tokens.count == 6
302
+ {
303
+ :second => parse_element(tokens[0], 0..59), #second
304
+ :minute => parse_element(tokens[1], 0..59), #minute
305
+ :hour => parse_element(tokens[2], 0..23), #hour
306
+ :dom => parse_element(tokens[3], 1..31), #DOM
307
+ :month => parse_element(tokens[4], 1..12), #mon
308
+ :dow => parse_element(tokens[5], 0..6), #DOW
309
+ :year => parse_element("*", 2000..2050) #year
310
+ }
311
+ else
312
+ {
313
+ :second => parse_element(tokens[0], 0..59), #second
314
+ :minute => parse_element(tokens[1], 0..59), #minute
315
+ :hour => parse_element(tokens[2], 0..23), #hour
316
+ :dom => parse_element(tokens[3], 1..31), #DOM
317
+ :month => parse_element(tokens[4], 1..12), #mon
318
+ :dow => parse_element(tokens[5], 0..6), #DOW
319
+ :year => parse_element(tokens[6], 2000..2050) #year
320
+ }
321
+ end
322
+ end
323
+ end
324
+
325
+ def substitute_parse_symbols(str)
326
+ SYMBOLS.inject(str.downcase) do |s, (symbol, replacement)|
327
+ s.gsub(symbol, replacement)
328
+ end
329
+ end
330
+
331
+
332
+ def stepped_range(rng, step = 1)
333
+ len = rng.last - rng.first
334
+
335
+ num = len.div(step)
336
+ result = (0..num).map { |i| rng.first + step * i }
337
+
338
+ result.pop if result[-1] == rng.last and rng.exclude_end?
339
+ result
340
+ end
341
+
342
+
343
+ # returns the smallest element from allowed which is greater than current
344
+ # returns nil if no matching value was found
345
+ def find_best_next(current, allowed, dir)
346
+ if dir == :next
347
+ allowed.sort.find { |val| val > current }
348
+ else
349
+ allowed.sort.reverse.find { |val| val < current }
350
+ end
351
+ end
352
+
353
+ def validate_source
354
+ unless @source.respond_to?(:split)
355
+ raise ArgumentError, 'not a valid cronline'
356
+ end
357
+ source_length = @source.split(/\s+/).length
358
+ unless (source_length >= 5 && source_length < 8)
359
+ raise ArgumentError, 'not a valid cronline'
360
+ end
361
+ end
362
+ end