schedulability 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +1 -0
- data/.editorconfig +16 -0
- data/.simplecov +9 -0
- data/ChangeLog +87 -0
- data/History.md +4 -0
- data/Manifest.txt +15 -0
- data/README.md +236 -0
- data/Rakefile +88 -0
- data/lib/schedulability.rb +28 -0
- data/lib/schedulability/exceptions.rb +16 -0
- data/lib/schedulability/mixins.rb +126 -0
- data/lib/schedulability/parser.rb +309 -0
- data/lib/schedulability/schedule.rb +214 -0
- data/spec/helpers.rb +45 -0
- data/spec/schedulability/schedule_spec.rb +828 -0
- data/spec/schedulability_spec.rb +13 -0
- metadata +216 -0
- metadata.gz.sig +0 -0
@@ -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
|