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.
- 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
|