schedulability 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,28 @@
1
+ # -*- ruby -*-
2
+ # vim: set nosta noet ts=4 sw=4:
3
+ # encoding: utf-8
4
+
5
+ require 'loggability'
6
+
7
+
8
+ # A mixin that provides scheduling to an including object.
9
+ module Schedulability
10
+ extend Loggability
11
+
12
+ # Package version constant
13
+ VERSION = '0.1.0'
14
+
15
+ # VCS revision
16
+ REVISION = %q$Revision: 39de155fb60f $
17
+
18
+
19
+ # Loggability API -- set up a logger for Schedulability objects
20
+ log_as :schedulability
21
+
22
+
23
+ autoload :Schedule, 'schedulability/schedule'
24
+ autoload :Parser, 'schedulability/parser'
25
+ autoload :TimeRefinements, 'schedulability/mixins'
26
+
27
+ end # module Schedulability
28
+
@@ -0,0 +1,16 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'schedulability' unless defined?( Schedulability )
5
+
6
+ # Schedulability namespace
7
+ module Schedulability
8
+
9
+ class Error < StandardError; end
10
+
11
+ class ParseError < Error; end
12
+
13
+ class RangeError < ParseError; end
14
+
15
+ end # module Arborist
16
+
@@ -0,0 +1,126 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'schedulability' unless defined?( Schedulability )
5
+
6
+ module Schedulability
7
+
8
+ # Functions for time calculations
9
+ module TimeFunctions
10
+
11
+ ###############
12
+ module_function
13
+ ###############
14
+
15
+ ### Calculate the (approximate) number of seconds that are in +count+ of the
16
+ ### given +unit+ of time.
17
+ ###
18
+ def calculate_seconds( count, unit )
19
+ return case unit
20
+ when :seconds, :second
21
+ count
22
+ when :minutes, :minute
23
+ count * 60
24
+ when :hours, :hour
25
+ count * 3600
26
+ when :days, :day
27
+ count * 86400
28
+ when :weeks, :week
29
+ count * 604800
30
+ when :fortnights, :fortnight
31
+ count * 1209600
32
+ when :months, :month
33
+ count * 2592000
34
+ when :years, :year
35
+ count * 31557600
36
+ else
37
+ raise ArgumentError, "don't know how to calculate seconds in a %p" % [ unit ]
38
+ end
39
+ end
40
+ end # module TimeFunctions
41
+
42
+
43
+ # Refinements to Numeric to add time-related convenience methods
44
+ module TimeRefinements
45
+ refine Numeric do
46
+
47
+ ### Number of seconds (returns receiver unmodified)
48
+ def seconds
49
+ return self
50
+ end
51
+ alias_method :second, :seconds
52
+
53
+ ### Returns number of seconds in <receiver> minutes
54
+ def minutes
55
+ return TimeFunctions.calculate_seconds( self, :minutes )
56
+ end
57
+ alias_method :minute, :minutes
58
+
59
+ ### Returns the number of seconds in <receiver> hours
60
+ def hours
61
+ return TimeFunctions.calculate_seconds( self, :hours )
62
+ end
63
+ alias_method :hour, :hours
64
+
65
+ ### Returns the number of seconds in <receiver> days
66
+ def days
67
+ return TimeFunctions.calculate_seconds( self, :day )
68
+ end
69
+ alias_method :day, :days
70
+
71
+ ### Return the number of seconds in <receiver> weeks
72
+ def weeks
73
+ return TimeFunctions.calculate_seconds( self, :weeks )
74
+ end
75
+ alias_method :week, :weeks
76
+
77
+ ### Returns the number of seconds in <receiver> fortnights
78
+ def fortnights
79
+ return TimeFunctions.calculate_seconds( self, :fortnights )
80
+ end
81
+ alias_method :fortnight, :fortnights
82
+
83
+ ### Returns the number of seconds in <receiver> months (approximate)
84
+ def months
85
+ return TimeFunctions.calculate_seconds( self, :months )
86
+ end
87
+ alias_method :month, :months
88
+
89
+ ### Returns the number of seconds in <receiver> years (approximate)
90
+ def years
91
+ return TimeFunctions.calculate_seconds( self, :years )
92
+ end
93
+ alias_method :year, :years
94
+
95
+
96
+ ### Returns the Time <receiver> number of seconds before the
97
+ ### specified +time+. E.g., 2.hours.before( header.expiration )
98
+ def before( time )
99
+ return time - self
100
+ end
101
+
102
+
103
+ ### Returns the Time <receiver> number of seconds ago. (e.g.,
104
+ ### expiration > 2.hours.ago )
105
+ def ago
106
+ return self.before( ::Time.now )
107
+ end
108
+
109
+
110
+ ### Returns the Time <receiver> number of seconds after the given +time+.
111
+ ### E.g., 10.minutes.after( header.expiration )
112
+ def after( time )
113
+ return time + self
114
+ end
115
+
116
+
117
+ ### Return a new Time <receiver> number of seconds from now.
118
+ def from_now
119
+ return self.after( ::Time.now )
120
+ end
121
+
122
+ end # refine Numeric
123
+ end # module TimeRefinements
124
+
125
+ end # module Schedulability
126
+
@@ -0,0 +1,309 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+
4
+ require 'loggability'
5
+ require 'schedulability' unless defined?( Schedulability )
6
+
7
+
8
+ # A collection of parsing functions for Schedulability schedule syntax.
9
+ module Schedulability::Parser
10
+ extend Loggability
11
+
12
+
13
+ log_to :schedulability
14
+
15
+
16
+ # A Regexp that will match valid period scale codes
17
+ VALID_SCALES = Regexp.union(%w[
18
+ year yr
19
+ month mo
20
+ week wk
21
+ yday yd
22
+ mday md
23
+ wday wd
24
+ hour hr
25
+ minute min
26
+ second sec
27
+ ])
28
+
29
+ # Scales that are parsed with exclusive end values.
30
+ EXCLUSIVE_RANGED_SCALES = %i[ hour hr minute min second sec ]
31
+
32
+ # The Regexp for matching value periods
33
+ PERIOD_PATTERN = %r:
34
+ (\A|\G\s+) # beginning of the string or the end of the last match
35
+ (?<scale> #{VALID_SCALES} )
36
+ s? # Optional plural sugar
37
+ \s*
38
+ \{
39
+ (?<ranges>.*?)
40
+ \}
41
+ :ix
42
+
43
+ # Pattern for matching +hour+-scale values
44
+ TIME_VALUE_PATTERN = /\A(?<hour>\d+)(?<qualifier>am|pm|noon)?\z/i
45
+
46
+ # Downcased day-name Arrays
47
+ ABBR_DAYNAMES = Date::ABBR_DAYNAMES.map( &:downcase )
48
+ DAYNAMES = Date::DAYNAMES.map( &:downcase )
49
+
50
+ # Downcased month-name Arrays
51
+ ABBR_MONTHNAMES = Date::ABBR_MONTHNAMES.map {|val| val && val.downcase }
52
+ MONTHNAMES = Date::MONTHNAMES.map {|val| val && val.downcase }
53
+
54
+
55
+ ###############
56
+ module_function
57
+ ###############
58
+
59
+ ### Scan +expression+ for periods and return them in an Array.
60
+ def extract_periods( expression )
61
+ positive_periods = []
62
+ negative_periods = []
63
+
64
+ expression.strip.downcase.split( /\s*,\s*/ ).each do |subexpr|
65
+ hash, negative = self.extract_period( subexpr )
66
+ if negative
67
+ self.log.debug "Adding %p to the negative "
68
+ negative_periods << hash
69
+ else
70
+ positive_periods << hash
71
+ end
72
+ end
73
+
74
+ return positive_periods, negative_periods
75
+ end
76
+
77
+
78
+ ### Return the specified period +expression+ as a Hash of Ranges keyed by scale.
79
+ def extract_period( expression )
80
+ hash = {}
81
+ scanner = StringScanner.new( expression )
82
+
83
+ negative = scanner.skip( /\s*(!|not |except )\s*/ )
84
+ self.log.debug "Period %p is %snegative!" % [ expression, negative ? "" : "not " ]
85
+
86
+ while scanner.scan( PERIOD_PATTERN )
87
+ ranges = scanner[:ranges].strip
88
+ scale = scanner[:scale]
89
+
90
+ case scale
91
+ when 'year', 'yr'
92
+ hash[:yr] = self.extract_year_ranges( ranges )
93
+ when 'month', 'mo'
94
+ hash[:mo] = self.extract_month_ranges( ranges )
95
+ when 'week', 'wk'
96
+ hash[:wk] = self.extract_week_ranges( ranges )
97
+ when 'yday', 'yd'
98
+ hash[:yd] = self.extract_yday_ranges( ranges )
99
+ when 'mday', 'md'
100
+ hash[:md] = self.extract_mday_ranges( ranges )
101
+ when 'wday', 'wd'
102
+ hash[:wd] = self.extract_wday_ranges( ranges )
103
+ when 'hour', 'hr'
104
+ hash[:hr] = self.extract_hour_ranges( ranges )
105
+ when 'minute', 'min'
106
+ hash[:min] = self.extract_minute_ranges( ranges )
107
+ when 'second', 'sec'
108
+ hash[:sec] = self.extract_second_ranges( ranges )
109
+ else
110
+ # This should never happen
111
+ raise ArgumentError, "Unhandled scale %p!" % [ scale ]
112
+ end
113
+ end
114
+
115
+ unless scanner.eos?
116
+ raise Schedulability::ParseError,
117
+ "malformed schedule (at %d: %p)" % [ scanner.pos, scanner.rest ]
118
+ end
119
+
120
+ return hash, negative
121
+ ensure
122
+ scanner.terminate if scanner
123
+ end
124
+
125
+
126
+ ### Return an Array of year integer Ranges for the specified +ranges+ expression.
127
+ def extract_year_ranges( ranges )
128
+ ranges = self.extract_ranges( :year, ranges, 2000, 9999 ) do |val|
129
+ Integer( val )
130
+ end
131
+
132
+ if ranges.any? {|rng| rng.end == 9999 }
133
+ raise Schedulability::ParseError, "no support for wrapped year ranges"
134
+ end
135
+
136
+ return ranges
137
+ end
138
+
139
+
140
+ ### Return an Array of month Integer Ranges for the specified +ranges+ expression.
141
+ def extract_month_ranges( ranges )
142
+ return self.extract_ranges( :month, ranges, 0, MONTHNAMES.size - 1 ) do |val|
143
+ self.map_integer_value( :month, val, [ABBR_MONTHNAMES, MONTHNAMES] )
144
+ end
145
+ end
146
+
147
+
148
+ ### Return an Array of week-of-month Integer Ranges for the specified +ranges+ expression.
149
+ def extract_week_ranges( ranges )
150
+ return self.extract_ranges( :week, ranges, 1, 5 ) do |val|
151
+ Integer( strip_leading_zeros(val) )
152
+ end
153
+ end
154
+
155
+
156
+ ### Return an Array of day-of-year Integer Ranges for the specified +ranges+ expression.
157
+ def extract_yday_ranges( ranges )
158
+ return self.extract_ranges( :yday, ranges, 1, 366 ) do |val|
159
+ Integer( strip_leading_zeros(val) )
160
+ end
161
+ end
162
+
163
+
164
+ ### Return an Array of day-of-month Integer Ranges for the specified +ranges+ expression.
165
+ def extract_mday_ranges( ranges )
166
+ return self.extract_ranges( :mday, ranges, 0, 31 ) do |val|
167
+ Integer( strip_leading_zeros(val) )
168
+ end
169
+ end
170
+
171
+
172
+ ### Return an Array of weekday Integer Ranges for the specified +ranges+ expression.
173
+ def extract_wday_ranges( ranges )
174
+ return self.extract_ranges( :wday, ranges, 0, DAYNAMES.size - 1 ) do |val|
175
+ self.map_integer_value( :wday, val, [ABBR_DAYNAMES, DAYNAMES] )
176
+ end
177
+ end
178
+
179
+
180
+ ### Return an Array of 24-hour Integer Ranges for the specified +ranges+ expression.
181
+ def extract_hour_ranges( ranges )
182
+ return self.extract_ranges( :hour, ranges, 0, 24 ) do |val|
183
+ self.extract_hour_value( val )
184
+ end
185
+ end
186
+
187
+
188
+ ### Return an Array of Integer minute Ranges for the specified +ranges+ expression.
189
+ def extract_minute_ranges( ranges )
190
+ return self.extract_ranges( :minute, ranges, 0, 59 ) do |val|
191
+ Integer( strip_leading_zeros(val) )
192
+ end
193
+ end
194
+
195
+
196
+ ### Return an Array of Integer second Ranges for the specified +ranges+ expression.
197
+ def extract_second_ranges( ranges )
198
+ return self.extract_ranges( :second, ranges, 0, 59 ) do |val|
199
+ Integer( strip_leading_zeros(val) )
200
+ end
201
+ end
202
+
203
+
204
+ ### Return the integer equivalent of the specified +time_value+.
205
+ def extract_hour_value( time_value )
206
+ unless match = TIME_VALUE_PATTERN.match( time_value )
207
+ raise Schedulability::ParseError, "invalid hour range: %p" % [ time_value ]
208
+ end
209
+
210
+ hour, qualifier = match[:hour], match[:qualifier]
211
+ hour = hour.to_i
212
+
213
+ if qualifier
214
+ raise Schedulability::RangeError, "invalid hour value: %p" % [ time_value ] if
215
+ hour > 12
216
+ hour += 12 if qualifier == 'pm' && hour < 12
217
+ else
218
+ raise Schedulability::RangeError, "invalid hour value: %p" % [ time_value ] if
219
+ hour > 24
220
+ hour = 24 if hour.zero?
221
+ end
222
+
223
+ return hour
224
+ end
225
+
226
+
227
+ ### Extract an Array of Ranges from the specified +ranges+ string using the given
228
+ ### +index_arrays+ for non-numeric values. Construct the Ranges with the given
229
+ ### +minval+/+maxval+ range boundaries.
230
+ def extract_ranges( scale, ranges, minval, maxval )
231
+ exclude_end = EXCLUSIVE_RANGED_SCALES.include?( scale )
232
+ valid_range = Range.new( minval, maxval, exclude_end )
233
+
234
+ ints = ranges.split( /(?<!-)\s+(?!-)/ ).flat_map do |range|
235
+ min, max = range.split( /\s*-\s*/, 2 )
236
+ self.log.debug "Min = %p, max = %p" % [ min, max ]
237
+
238
+ min = yield( min )
239
+ raise Schedulability::ParseError, "invalid %s value: %p" % [ scale, min ] unless
240
+ valid_range.cover?( min )
241
+ next [ min ] unless max
242
+
243
+ max = yield( max )
244
+ raise Schedulability::ParseError, "invalid %s value: %p" % [ scale, max ] unless
245
+ valid_range.cover?( max )
246
+ self.log.debug "Parsed min = %p, max = %p" % [ min, max ]
247
+
248
+ if min > max
249
+ self.log.debug "wrapped: %d-%d and %d-%d" % [ minval, max, min, maxval ]
250
+ Range.new( minval, max, exclude_end ).to_a +
251
+ Range.new( min, maxval, false ).to_a
252
+ else
253
+ Range.new( min, max, exclude_end ).to_a
254
+ end
255
+ end
256
+
257
+ return self.coalesce_ranges( ints, scale )
258
+ end
259
+
260
+
261
+ ### Coalese an Array of non-contiguous Range objects from the specified +ints+ for +scale+.
262
+ def coalesce_ranges( ints, scale )
263
+ exclude_end = EXCLUSIVE_RANGED_SCALES.include?( scale )
264
+ self.log.debug "Coalescing %d ints to Ranges (%p, %s)" %
265
+ [ ints.size, ints, exclude_end ? "exclusive" : "inclusive" ]
266
+ ints.flatten!
267
+ return [] if ints.empty?
268
+
269
+ prev = ints[0]
270
+ range_ints = ints.sort.slice_before do |v|
271
+ prev, prev2 = v, prev
272
+ prev2.succ != v
273
+ end
274
+
275
+ return range_ints.map do |values|
276
+ last_val = values.last
277
+ last_val += 1 if exclude_end
278
+ Range.new( values.first, last_val, exclude_end )
279
+ end.tap do |ranges|
280
+ self.log.debug "Coalesced range integers to Ranges: %p" % [ ranges ]
281
+ end
282
+ end
283
+
284
+
285
+ ### Map a +value+ from a period's range to an Integer, using the specified +index_arrays+
286
+ ### if it doesn't look like an integer string.
287
+ def map_integer_value( scale, value, index_arrays )
288
+ return Integer( value ) if value =~ /\A\d+\z/
289
+
290
+ unless index = index_arrays.inject( nil ) {|res, ary| res || ary.index(value) }
291
+ expected = "expected one of: %s, %d-%d" % [
292
+ index_arrays.flatten.compact.flatten.join( ', ' ),
293
+ index_arrays.first.index {|val| val },
294
+ index_arrays.first.size - 1
295
+ ]
296
+ raise Schedulability::ParseError, "invalid %s value: %p (%s)" %
297
+ [ scale, value, expected ]
298
+ end
299
+
300
+ return index
301
+ end
302
+
303
+
304
+ ### Return a copy of the specified +val+ with any leading zeros stripped.
305
+ def strip_leading_zeros( val )
306
+ return val.sub( /\A0+/, '' )
307
+ end
308
+
309
+ end # module Schedulability::Parser