schedulability 0.1.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.
@@ -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