montrose 0.10.0 → 0.12.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.
@@ -26,7 +26,7 @@ module Montrose
26
26
  if @at
27
27
  times = @at.map { |hour, min, sec = 0| @time.change(hour: hour, min: min, sec: sec) }
28
28
 
29
- min_next = times.select { |t| t > @time }.min and return min_next
29
+ (min_next = times.select { |t| t > @time }.min) && (return min_next)
30
30
 
31
31
  advance_step(times.min || @time)
32
32
  else
@@ -41,7 +41,7 @@ module Montrose
41
41
  end
42
42
 
43
43
  def step
44
- @step ||= smallest_step or fail ConfigurationError, "No step for #{@options.inspect}"
44
+ (@step ||= smallest_step) || fail(ConfigurationError, "No step for #{@options.inspect}")
45
45
  end
46
46
 
47
47
  def smallest_step
@@ -76,9 +76,9 @@ module Montrose
76
76
  is_frequency = @every == unit
77
77
  if ([unit] + alternates).any? { |u| @options.key?(u) } && !is_frequency
78
78
  # smallest unit, increment by 1
79
- { step_key(unit) => 1 }
79
+ {step_key(unit) => 1}
80
80
  elsif is_frequency
81
- { step_key(unit) => @interval }
81
+ {step_key(unit) => @interval}
82
82
  end
83
83
  end
84
84
 
@@ -30,17 +30,17 @@ module Montrose
30
30
  #
31
31
  def self.from_options(opts)
32
32
  frequency = opts.fetch(:every) { fail ConfigurationError, "Please specify the :every option" }
33
- class_name = FREQUENCY_TERMS.fetch(frequency.to_s) do
33
+ class_name = FREQUENCY_TERMS.fetch(frequency.to_s) {
34
34
  fail "Don't know how to enumerate every: #{frequency}"
35
- end
35
+ }
36
36
 
37
37
  Montrose::Frequency.const_get(class_name).new(opts)
38
38
  end
39
39
 
40
40
  # @private
41
41
  def self.assert(frequency)
42
- FREQUENCY_TERMS.key?(frequency.to_s) or fail ConfigurationError,
43
- "Don't know how to enumerate every: #{frequency}"
42
+ FREQUENCY_TERMS.key?(frequency.to_s) || fail(ConfigurationError,
43
+ "Don't know how to enumerate every: #{frequency}")
44
44
 
45
45
  frequency.to_sym
46
46
  end
@@ -86,6 +86,8 @@ module Montrose
86
86
  def_option :starts
87
87
  def_option :until
88
88
  def_option :between
89
+ def_option :covering
90
+ def_option :during
89
91
  def_option :hour
90
92
  def_option :day
91
93
  def_option :mday
@@ -119,12 +121,12 @@ module Montrose
119
121
  end
120
122
 
121
123
  def to_hash
122
- hash_pairs = self.class.defined_options.flat_map do |opt_name|
124
+ hash_pairs = self.class.defined_options.flat_map { |opt_name|
123
125
  [opt_name, send(opt_name)]
124
- end
126
+ }
125
127
  Hash[*hash_pairs].reject { |_k, v| v.nil? }
126
128
  end
127
- alias to_h to_hash
129
+ alias_method :to_h, :to_hash
128
130
 
129
131
  def []=(option, val)
130
132
  send(:"#{option}=", val)
@@ -141,13 +143,13 @@ module Montrose
141
143
  self.class.new(h1.merge(h2))
142
144
  end
143
145
 
144
- def fetch(key, *args, &_block)
145
- fail ArgumentError, "wrong number of arguments (#{args.length} for 1..2)" if args.length > 1
146
+ def fetch(key, *args)
147
+ raise ArgumentError, "wrong number of arguments (#{args.length} for 1..2)" if args.length > 1
146
148
 
147
149
  found = send(key)
148
150
  return found if found
149
151
  return args.first if args.length == 1
150
- fail "Key #{key.inspect} not found" unless block_given?
152
+ raise "Key #{key.inspect} not found" unless block
151
153
 
152
154
  yield
153
155
  end
@@ -164,8 +166,8 @@ module Montrose
164
166
  @every = parsed.fetch(:every)
165
167
  end
166
168
 
167
- alias frequency every
168
- alias frequency= every=
169
+ alias_method :frequency, :every
170
+ alias_method :frequency=, :every=
169
171
 
170
172
  def starts=(time)
171
173
  @starts = normalize_time(as_time(time)) || default_starts
@@ -179,6 +181,15 @@ module Montrose
179
181
  @hour = map_arg(hours) { |h| assert_hour(h) }
180
182
  end
181
183
 
184
+ def during=(during)
185
+ @during = case during
186
+ when Range
187
+ [decompose_during_arg(during)]
188
+ else
189
+ map_arg(during) { |d| decompose_during_arg(d) }
190
+ end
191
+ end
192
+
182
193
  def day=(days)
183
194
  @day = nested_map_arg(days) { |d| day_number!(d) }
184
195
  end
@@ -200,7 +211,9 @@ module Montrose
200
211
  end
201
212
 
202
213
  def between=(range)
203
- @between = range
214
+ if Montrose.enable_deprecated_between_masking?
215
+ @covering = range
216
+ end
204
217
  self[:starts] = range.first unless self[:starts]
205
218
  self[:until] = range.last unless self[:until]
206
219
  end
@@ -302,7 +315,7 @@ module Montrose
302
315
  result[:mday] += map_mdays(v)
303
316
  end
304
317
  else
305
- { day: map_days(arg) }
318
+ {day: map_days(arg)}
306
319
  end
307
320
  end
308
321
 
@@ -313,12 +326,12 @@ module Montrose
313
326
  day = day_number(key)
314
327
  return [:day, day] if day
315
328
 
316
- fail ConfigurationError, "Did not recognize #{key} as a month or day"
329
+ raise ConfigurationError, "Did not recognize #{key} as a month or day"
317
330
  end
318
331
 
319
332
  def assert_range_includes(range, item, absolute = false)
320
333
  test = absolute ? item.abs : item
321
- fail ConfigurationError, "Out of range: #{range.inspect} does not include #{test}" unless range.include?(test)
334
+ raise ConfigurationError, "Out of range: #{range.inspect} does not include #{test}" unless range.include?(test)
322
335
 
323
336
  item
324
337
  end
@@ -333,18 +346,18 @@ module Montrose
333
346
  def parse_frequency(input)
334
347
  if input.respond_to?(:parts)
335
348
  frequency, interval = duration_to_frequency_parts(input)
336
- { every: frequency.to_s.singularize.to_sym, interval: interval }
349
+ {every: frequency.to_s.singularize.to_sym, interval: interval}
337
350
  elsif input.is_a?(Numeric)
338
351
  frequency, interval = numeric_to_frequency_parts(input)
339
- { every: frequency, interval: interval }
352
+ {every: frequency, interval: interval}
340
353
  else
341
- { every: Frequency.assert(input) }
354
+ {every: Frequency.assert(input)}
342
355
  end
343
356
  end
344
357
 
345
358
  def numeric_to_frequency_parts(number)
346
359
  parts = nil
347
- [:year, :month, :week, :day, :hour, :minute].each do |freq|
360
+ %i[year month week day hour minute].each do |freq|
348
361
  div, mod = number.divmod(1.send(freq))
349
362
  parts = [freq, div]
350
363
  return parts if mod.zero?
@@ -355,5 +368,16 @@ module Montrose
355
368
  def duration_to_frequency_parts(duration)
356
369
  duration.parts.first
357
370
  end
371
+
372
+ def decompose_during_arg(during)
373
+ case during
374
+ when Range
375
+ [decompose_during_arg(during.first), decompose_during_arg(during.last)]
376
+ when String
377
+ during.split(/[-—–]/).map { |d| as_time_parts(d) }
378
+ else
379
+ as_time_parts(during)
380
+ end
381
+ end
358
382
  end
359
383
  end
@@ -238,7 +238,7 @@ module Montrose
238
238
  else
239
239
  fail SerializationError,
240
240
  "Object was supposed to be a #{self}, but was a #{obj.class}. -- #{obj.inspect}"
241
- end
241
+ end
242
242
 
243
243
  JSON.dump(hash)
244
244
  end
@@ -308,7 +308,7 @@ module Montrose
308
308
  def to_hash
309
309
  default_options.to_hash
310
310
  end
311
- alias to_h to_hash
311
+ alias_method :to_h, :to_hash
312
312
 
313
313
  # Returns json string of options used to create the recurrence
314
314
  #
@@ -346,20 +346,41 @@ module Montrose
346
346
  def include?(timestamp)
347
347
  return false if earlier?(timestamp) || later?(timestamp)
348
348
 
349
- recurrence = finite? ? self : starts(timestamp)
349
+ recurrence = finite? ? self : fast_forward(timestamp)
350
350
 
351
351
  recurrence.events.lazy.each do |event|
352
352
  return true if event == timestamp
353
353
  return false if event > timestamp
354
- end or false
354
+ end || false
355
355
  end
356
356
 
357
- # Return true/false if recurrence will iterate infinitely
357
+ def fast_forward(timestamp)
358
+ return starts(timestamp) unless starts_at.present?
359
+
360
+ interval = default_options[:interval]
361
+ frequency = default_options[:every]
362
+ duration = interval.send(frequency)
363
+
364
+ # Calculate nearest earlier time ahead matching frequency * interval
365
+ jump = ((timestamp - starts_at) / duration).floor * duration
366
+
367
+ starts(starts_at + jump)
368
+ end
369
+
370
+ # Return true/false if recurrence will terminate
358
371
  #
359
- # @return [Boolean] whether or not recurrence is infinite
372
+ # @return [Boolean] returns true if recurrence has an end
360
373
  #
361
374
  def finite?
362
- ends_at || length
375
+ !infinite?
376
+ end
377
+
378
+ # Return true/false if recurrence will iterate infinitely
379
+ #
380
+ # @return [Boolean] returns true if recurrence has no end
381
+ #
382
+ def infinite?
383
+ !ends_at && !length
363
384
  end
364
385
 
365
386
  # Return true/false if given timestamp occurs before
@@ -391,7 +412,7 @@ module Montrose
391
412
  loop do
392
413
  stack.advance(clock.tick) do |time|
393
414
  yielder << time
394
- end or break
415
+ end || break
395
416
  end
396
417
  end
397
418
  end
data/lib/montrose/rule.rb CHANGED
@@ -36,10 +36,11 @@ module Montrose
36
36
  end
37
37
 
38
38
  require "montrose/rule/after"
39
- require "montrose/rule/between"
39
+ require "montrose/rule/covering"
40
40
  require "montrose/rule/day_of_month"
41
41
  require "montrose/rule/day_of_week"
42
42
  require "montrose/rule/day_of_year"
43
+ require "montrose/rule/during"
43
44
  require "montrose/rule/except"
44
45
  require "montrose/rule/hour_of_day"
45
46
  require "montrose/rule/month_of_year"
@@ -22,7 +22,7 @@ module Montrose
22
22
  end
23
23
 
24
24
  def continue?(time)
25
- @between.max > time
25
+ time < @between.max
26
26
  end
27
27
  end
28
28
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Montrose
4
+ module Rule
5
+ class Covering
6
+ include Montrose::Rule
7
+
8
+ def self.apply_options(opts)
9
+ opts[:covering].is_a?(Range) && opts[:covering]
10
+ end
11
+
12
+ # Initializes rule
13
+ #
14
+ # @param [Range] covering - timestamp range
15
+ #
16
+ def initialize(covering)
17
+ @covering = case covering.first
18
+ when Date
19
+ DateRange.new(covering)
20
+ else
21
+ covering
22
+ end
23
+ end
24
+
25
+ def include?(time)
26
+ @covering.include?(time)
27
+ end
28
+
29
+ def continue?(time)
30
+ time < @covering.last
31
+ end
32
+
33
+ class DateRange < SimpleDelegator
34
+ def include?(time)
35
+ __getobj__.include?(time.to_date)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Montrose
4
+ module Rule
5
+ class During
6
+ include Montrose::Rule
7
+
8
+ def self.apply_options(opts)
9
+ opts[:during]
10
+ end
11
+
12
+ # Initializes rule
13
+ #
14
+ # @param during [Array<Array<Fixnum>>] array of time parts arrays, e.g. [[9, 0, 0], [17, 0, 0]], i.e., "9 to 5"
15
+ #
16
+ def initialize(during)
17
+ @during = during.map { |first, last| TimeOfDayRange.new(first, last) }
18
+ end
19
+
20
+ def include?(time)
21
+ @during.any? { |range| range.include?(time) }
22
+ end
23
+
24
+ class TimeOfDay
25
+ def initialize(hour, min, sec)
26
+ @hour = hour
27
+ @min = min
28
+ @sec = sec
29
+ end
30
+
31
+ def seconds_since_midnight
32
+ @seconds_since_midnight ||= (@hour * 60 * 60) + (@min * 60) + @sec
33
+ end
34
+ end
35
+
36
+ class TimeOfDayRange
37
+ def initialize(first, last, exclude_end: false)
38
+ @first = TimeOfDay.new(*first)
39
+ @last = TimeOfDay.new(*last)
40
+ @exclude_end = exclude_end
41
+ end
42
+
43
+ def include?(time)
44
+ range.include?(time.seconds_since_midnight.to_i)
45
+ end
46
+
47
+ private
48
+
49
+ def range
50
+ @range ||= Range.new(@first.seconds_since_midnight, @last.seconds_since_midnight, @exclude_end)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -8,7 +8,7 @@ module Montrose
8
8
  def self.apply_options(opts)
9
9
  return false unless opts[:until]
10
10
 
11
- { until: opts[:until], exclude_end: opts.fetch(:exclude_end, false) }
11
+ {until: opts[:until], exclude_end: opts.fetch(:exclude_end, false)}
12
12
  end
13
13
 
14
14
  def initialize(opts)
@@ -8,27 +8,56 @@ module Montrose
8
8
  # @attr_reader [Array] rules the list of recurrences
9
9
  #
10
10
  class Schedule
11
+ include Enumerable
12
+
11
13
  attr_accessor :rules
12
14
 
13
- # Instantiates a schedule and yields the instance to an optional
14
- # block for building recurrences inline
15
- #
16
- # @example Build a schedule with multiple rules added in the given block
17
- # schedule = Montrose::Schedule.build do |s|
18
- # s << { every: :day }
19
- # s << { every: :year }
20
- # end
21
- #
22
- # @return [Montrose::Schedule]
23
- #
24
- def self.build
25
- schedule = new
26
- yield schedule if block_given?
27
- schedule
15
+ class << self
16
+ # Instantiates a schedule and yields the instance to an optional
17
+ # block for building recurrences inline
18
+ #
19
+ # @example Build a schedule with multiple rules added in the given block
20
+ # schedule = Montrose::Schedule.build do |s|
21
+ # s << { every: :day }
22
+ # s << { every: :year }
23
+ # end
24
+ #
25
+ # @return [Montrose::Schedule]
26
+ #
27
+ def build
28
+ schedule = new
29
+ yield schedule if block_given?
30
+ schedule
31
+ end
32
+
33
+ def dump(obj)
34
+ return nil if obj.nil?
35
+ return dump(load(obj)) if obj.is_a?(String)
36
+
37
+ array = case obj
38
+ when Array
39
+ new(obj).to_a
40
+ when self
41
+ obj.to_a
42
+ else
43
+ fail SerializationError,
44
+ "Object was supposed to be a #{self}, but was a #{obj.class}. -- #{obj.inspect}"
45
+ end
46
+
47
+ JSON.dump(array)
48
+ end
49
+
50
+ def load(json)
51
+ return nil if json.blank?
52
+
53
+ new JSON.parse(json)
54
+ rescue JSON::ParserError => e
55
+ fail SerializationError, "Could not parse JSON: #{e}"
56
+ end
28
57
  end
29
58
 
30
- def initialize
31
- @rules = []
59
+ def initialize(rules = [])
60
+ @rules = rules.map { |rule| Montrose::Recurrence.new(rule) }
32
61
  end
33
62
 
34
63
  # Add a recurrence rule to the schedule, either by hash or recurrence
@@ -48,7 +77,7 @@ module Montrose
48
77
 
49
78
  self
50
79
  end
51
- alias add <<
80
+ alias_method :add, :<<
52
81
 
53
82
  # Return true/false if given timestamp is included in any of the rules
54
83
  # found in the schedule
@@ -59,6 +88,31 @@ module Montrose
59
88
  @rules.any? { |r| r.include?(timestamp) }
60
89
  end
61
90
 
91
+ # Iterate over the events of a recurrence. Along with the Enumerable
92
+ # module, this makes Montrose occurrences enumerable like other Ruby
93
+ # collections
94
+ #
95
+ # @example Iterate over a finite recurrence
96
+ # schedule = Montrose::Schedule.build do |s|
97
+ # s << { every: :day }
98
+ # end
99
+ # schedule.each do |event|
100
+ # puts event
101
+ # end
102
+ #
103
+ # @example Iterate over an infinite recurrence
104
+ # schedule = Montrose::Schedule.build do |s|
105
+ # s << { every: :day }
106
+ # end
107
+ # schedule.lazy.each do |event|
108
+ # puts event
109
+ # end
110
+ #
111
+ # @return [Enumerator] an enumerator of recurrence timestamps
112
+ def each(&block)
113
+ events.each(&block)
114
+ end
115
+
62
116
  # Returns an enumerator for iterating over timestamps in the schedule
63
117
  #
64
118
  # @example Return the events
@@ -73,21 +127,55 @@ module Montrose
73
127
  enums = @rules.map { |r| r.merge(opts).events }
74
128
  Enumerator.new do |y|
75
129
  loop do
76
- enum = active_enums(enums).min_by(&:peek) or break
130
+ (enum = active_enums(enums).min_by(&:peek)) || break
77
131
  y << enum.next
78
132
  end
79
133
  end
80
134
  end
81
135
 
136
+ # Returns an array of the options used to create the recurrence
137
+ #
138
+ # @return [Array] array of hashes of recurrence options
139
+ #
140
+ def to_a
141
+ @rules.map(&:to_hash)
142
+ end
143
+
144
+ # Returns json string of options used to create the schedule
145
+ #
146
+ # @return [String] json of schedule recurrences
147
+ #
148
+ def to_json(*args)
149
+ JSON.dump(to_a, *args)
150
+ end
151
+
152
+ # Returns json array of options used to create the schedule
153
+ #
154
+ # @return [Array] json of schedule recurrence options
155
+ #
156
+ def as_json(*args)
157
+ to_a.as_json(*args)
158
+ end
159
+
160
+ # Returns options used to create the schedule recurrences in YAML format
161
+ #
162
+ # @return [String] YAML-formatted schedule recurrence options
163
+ #
164
+ def to_yaml(*args)
165
+ YAML.dump(JSON.parse(to_json(*args)))
166
+ end
167
+
168
+ def inspect
169
+ "#<#{self.class}:#{object_id.to_s(16)} #{to_a.inspect}>"
170
+ end
171
+
82
172
  private
83
173
 
84
174
  def active_enums(enums)
85
175
  enums.select do |e|
86
- begin
87
- e.peek
88
- rescue StopIteration
89
- false
90
- end
176
+ e.peek
177
+ rescue StopIteration
178
+ false
91
179
  end
92
180
  end
93
181
  end