class Fugit::Cron

Constants

FREQUENCY_CACHE
MAXDAYS
MAX_ITERATION_COUNT
SLOTS
SPECIALS

Attributes

hours[R]
minutes[R]
monthdays[R]
months[R]
original[R]
seconds[R]
timezone[R]
weekdays[R]
zone[R]

Public Class Methods

do_parse(s, opts={}) click to toggle source
# File lib/fugit/cron.rb, line 54
def do_parse(s, opts={})

  parse(s, opts) ||
  fail(ArgumentError.new("invalid cron string #{trunc(s)}"))
end
new(original) click to toggle source
# File lib/fugit/cron.rb, line 27
def new(original)

  parse(original)
end
parse(s, opts={}) click to toggle source
# File lib/fugit/cron.rb, line 32
      def parse(s, opts={})

        return s if s.is_a?(self)
        return nil unless s.is_a?(String)

        s0 = s
        s = s.strip

        s =
          if s[0, 1] == '@'
            ss = s.split(/\s+/, 2)
            [ SPECIALS[ss[0]] || ss, *ss[1..-1] ].join(' ')
          else
            s
          end

#p s; Raabro.pp(Parser.parse(s, debug: 3), colors: true)
        h = Parser.parse(s)

        self.allocate.send(:init, s0, h, opts)
      end

Protected Class Methods

trunc(s) click to toggle source
# File lib/fugit/cron.rb, line 62
def trunc(s)

  if s.is_a?(String)
    r = s.length > 28 ? s[0, 28] + "... len #{s.length}" : s
    r.inspect
  else
    r = s.inspect
    r.length > 35 ? s[0, 35] + '...' : r
  end
end

Public Instance Methods

==(o) click to toggle source
# File lib/fugit/cron.rb, line 506
def ==(o)

  o.is_a?(::Fugit::Cron) && o.to_a == to_a
end
Also aliased as: eql?
brute_frequency(year=2017) click to toggle source

Mostly used as a next_time sanity check. Avoid for “business” use, it's slow.

2017 is a non leap year (though it is preceded by a leap second on 2016-12-31)

Nota bene: cron with seconds are not supported.

# File lib/fugit/cron.rb, line 404
def brute_frequency(year=2017)

  FREQUENCY_CACHE["#{to_cron_s}|#{year}"] ||=
    begin

      deltas = []

      t = EtOrbi.make_time("#{year}-01-01") - 1
      t0 = nil
      t1 = nil

      loop do
        t1 = next_time(t)
        deltas << (t1 - t).to_i if t0
        t0 ||= t1
        break if deltas.any? && t1.year > year
        break if t1.year - t0.year > 7
        t = t1
      end

      Frequency.new(deltas, t1 - t0)
    end
end
day_match?(nt) click to toggle source
# File lib/fugit/cron.rb, line 215
def day_match?(nt)

  if @weekdays && @monthdays

    return weekday_match?(nt) && monthday_match?(nt) \
      if @day_and
        #
        # extension for fugit, gh-78

    return weekday_match?(nt) || monthday_match?(nt)
      #
      # From `man 5 crontab`
      #
      # Note: The day of a command's execution can be specified
      # by two fields -- day of month, and day of week.
      # If both fields are restricted (ie, are not *), the command will be
      # run when either field matches the current time.
      # For example, ``30 4 1,15 * 5'' would cause a command to be run
      # at 4:30 am on the 1st and 15th of each month, plus every Friday.
      #
      # as seen in gh-5 and gh-35
  end


  return false unless weekday_match?(nt)
  return false unless monthday_match?(nt)

  true
end
eql?(o)
Alias for: ==
hash() click to toggle source
# File lib/fugit/cron.rb, line 512
def hash

  to_a.hash
end
hour_match?(nt) click to toggle source
# File lib/fugit/cron.rb, line 172
def hour_match?(nt); ( ! @hours) || @hours.include?(nt.hour); end
match?(t) click to toggle source
# File lib/fugit/cron.rb, line 245
def match?(t)

  t = Fugit.do_parse_at(t).translate(@timezone)

  month_match?(t) && day_match?(t) &&
  hour_match?(t) && min_match?(t) && sec_match?(t)
end
min_match?(nt) click to toggle source
# File lib/fugit/cron.rb, line 173
def min_match?(nt); ( ! @minutes) || @minutes.include?(nt.min); end
month_match?(nt) click to toggle source
# File lib/fugit/cron.rb, line 171
def month_match?(nt); ( ! @months) || @months.include?(nt.month); end
monthday_match?(nt) click to toggle source
# File lib/fugit/cron.rb, line 204
def monthday_match?(nt)

  return true if @monthdays.nil?

  last = (TimeCursor.new(self, nt).inc_month.time - 24 * 3600).day + 1

  @monthdays
    .collect { |d| d < 1 ? last + d : d }
    .include?(nt.day)
end
next(from=::EtOrbi::EoTime.now) click to toggle source

Returns an ::Enumerable instance that yields each “next time” in succession

# File lib/fugit/cron.rb, line 369
def next(from=::EtOrbi::EoTime.now)

  CronIterator.new(self, :next_time, from)
end
next_time(from=::EtOrbi::EoTime.now) click to toggle source

See gh-15 and tst/iteration_count.rb

Initially set to 1024 after seeing the worst case for next_time at 167 iterations, I placed it at 2048 after experimenting with gh-18 and noticing some > 1024 for some experiments. 2048 should be ok.

# File lib/fugit/cron.rb, line 262
    def next_time(from=::EtOrbi::EoTime.now)

      from = ::EtOrbi.make_time(from)
      sfrom = from.strftime('%F|%T')
      ifrom = from.to_i

      i = 0
      t = TimeCursor.new(self, from.translate(@timezone))
        #
        # the translation occurs in the timezone of
        # this Fugit::Cron instance

      zfrom = t.time.strftime('%z|%Z')

      loop do

        fail RuntimeError.new(
          "too many loops for #{@original.inspect} #next_time, breaking, " +
          "cron expression most likely invalid (Feb 30th like?), " +
          "please fill an issue at https://git.io/fjJC9"
        ) if (i += 1) > MAX_ITERATION_COUNT

#tt = t.time;
#puts "  #{tt.strftime('%F %T %:z %A')} #{tt.rweek} #{tt.rweek % 2}"
        (ifrom == t.to_i) && (t.inc(1); next)
        month_match?(t) || (t.inc_month; next)
        day_match?(t) || (t.inc_day; next)
        hour_match?(t) || (t.inc_hour; next)
        min_match?(t) || (t.inc_min; next)
        sec_match?(t) || (t.inc_sec; next)

        tt = t.time
        st = tt.strftime('%F|%T')
        zt = tt.strftime('%z|%Z')
          #
        if st == sfrom && zt != zfrom
          from, sfrom, zfrom, ifrom = tt, st, zt, t.to_i
          next
        end
          #
          # when transitioning out of DST, this prevents #next_time from
          # yielding the same literal time twice in a row, see gh-6

        break
      end

      t.time.translate(from.zone)
        #
        # the answer time is in the same timezone as the `from`
        # starting point
    end
prev(from=::EtOrbi::EoTime.now) click to toggle source

Returns an ::Enumerable instance that yields each “previous time” in succession

# File lib/fugit/cron.rb, line 377
def prev(from=::EtOrbi::EoTime.now)

  CronIterator.new(self, :previous_time, from)
end
previous_time(from=::EtOrbi::EoTime.now) click to toggle source
# File lib/fugit/cron.rb, line 314
    def previous_time(from=::EtOrbi::EoTime.now)

      from = ::EtOrbi.make_time(from)

      i = 0
      t = TimeCursor.new(self, (from - 1).translate(@timezone))

      loop do

        fail RuntimeError.new(
          "too many loops for #{@original.inspect} #previous_time, breaking, " +
          "cron expression most likely invalid (Feb 30th like?), " +
          "please fill an issue at https://git.io/fjJCQ"
        ) if (i += 1) > MAX_ITERATION_COUNT

#tt = t.time;
#puts "  #{tt.strftime('%F %T %:z %A')} #{tt.rweek} #{tt.rweek % 4}"
        month_match?(t) || (t.dec_month; next)
        day_match?(t) || (t.dec_day; next)
        hour_match?(t) || (t.dec_hour; next)
        min_match?(t) || (t.dec_min; next)
        sec_match?(t) || (t.dec_sec; next)
        break
      end

      t.time.translate(from.zone)
    end
rough_frequency() click to toggle source
# File lib/fugit/cron.rb, line 434
def rough_frequency

  slots = SLOTS
    .collect { |k, v0, v1|
      a = (k == :days) ? rough_days : instance_variable_get("@#{k}")
      [ k, v0, v1, a ] }

  slots.each do |k, v0, _, a|
    next if a == [ 0 ]
    break if a != nil
    return v0 if a == nil
  end

  slots.each do |k, v0, v1, a|
    next unless a && a.length > 1
    return (a + [ a.first + v1 ])
      .each_cons(2)
      .collect { |a0, a1| a1 - a0 }
      .select { |d| d > 0 } # weed out zero deltas
      .min * v0
  end

  slots.reverse.each do |k, v0, v1, a|
    return v0 * v1 if a && a.length == 1
  end

  1 # second
end
sec_match?(nt) click to toggle source
# File lib/fugit/cron.rb, line 174
def sec_match?(nt); ( ! @seconds) || @seconds.include?(nt.sec); end
to_a() click to toggle source
# File lib/fugit/cron.rb, line 491
def to_a

  [ @seconds, @minutes, @hours, @monthdays, @months, @weekdays ]
end
to_cron_s() click to toggle source
# File lib/fugit/cron.rb, line 74
def to_cron_s

  @cron_s ||= [
    @seconds == [ 0 ] ? nil : (@seconds || [ '*' ]).join(','),
    (@minutes || [ '*' ]).join(','),
    (@hours || [ '*' ]).join(','),
    (@monthdays || [ '*' ]).join(','),
    (@months || [ '*' ]).join(','),
    weekdays_to_cron_s,
    @timezone ? @timezone.name : nil
      ].compact.join(' ')
end
to_h() click to toggle source
# File lib/fugit/cron.rb, line 496
def to_h

  { seconds: @seconds,
    minutes: @minutes,
    hours: @hours,
    monthdays: @monthdays,
    months: @months,
    weekdays: @weekdays }
end
weekday_hash_match?(nt, hsh) click to toggle source
# File lib/fugit/cron.rb, line 176
def weekday_hash_match?(nt, hsh)

  return false unless hsh.is_a?(Integer)

  phsh, nhsh = nt.wday_in_month
    #
    # positive wday, from the beginning of the month
    # negative wday, from the end of the month, -1 == last

  (hsh == phsh) || (hsh == nhsh)
end
weekday_match?(nt) click to toggle source
# File lib/fugit/cron.rb, line 194
def weekday_match?(nt)

  @weekdays.nil? ||
  @weekdays.find { |wd, hom|
    (nt.wday == wd) &&
    (hom.nil? ||
     weekday_modulo_match?(nt, hom) ||
     weekday_hash_match?(nt, hom)) }
end
weekday_modulo_match?(nt, mod) click to toggle source
# File lib/fugit/cron.rb, line 188
def weekday_modulo_match?(nt, mod)

  mod.is_a?(Array) &&
  ((nt.rweek % mod[0]) == (mod[1] % mod[0]))
end
within(time_range, time_end=nil) click to toggle source

Returns an array of EtOrbi::EoTime instances that correspond to the occurrences of the cron within the given time range

# File lib/fugit/cron.rb, line 385
def within(time_range, time_end=nil)

  sta, ned =
    time_range.is_a?(::Range) ? [ time_range.begin, time_range.end ] :
    [ ::EtOrbi.make_time(time_range), ::EtOrbi.make_time(time_end) ]

  CronIterator
    .new(self, :next_time, sta)
    .take_while { |eot| eot.to_t < ned }
end

Protected Instance Methods

compact_month_days() click to toggle source
# File lib/fugit/cron.rb, line 519
def compact_month_days

  return true if @months == nil || @monthdays == nil

  ms, ds =
    @months.inject([ [], [] ]) { |a, m|
      @monthdays.each { |d|
        next if d > MAXDAYS[m]
        a[0] << m; a[1] << d }
      a }
  @months = ms.uniq
  @monthdays = ds.uniq

  @months.any? && @monthdays.any?
end
determine_hours(arr, opts) click to toggle source
# File lib/fugit/cron.rb, line 698
def determine_hours(arr, opts)
  (@hours = do_determine(:hours, arr, 0, 23, opts)) != false
end
determine_minutes(arr, opts) click to toggle source
# File lib/fugit/cron.rb, line 694
def determine_minutes(arr, opts)
  (@minutes = do_determine(:minutes, arr, 0, 59, opts)) != false
end
determine_monthdays(arr, opts) click to toggle source
# File lib/fugit/cron.rb, line 702
def determine_monthdays(arr, opts)
  (@monthdays = do_determine(:monthdays, arr, 1, 31, opts)) != false
end
determine_months(arr, opts) click to toggle source
# File lib/fugit/cron.rb, line 706
def determine_months(arr, opts)
  (@months = do_determine(:months, arr, 1, 12, opts)) != false
end
determine_seconds(arr, opts) click to toggle source
# File lib/fugit/cron.rb, line 690
def determine_seconds(arr, opts)
  (@seconds = do_determine(:seconds, arr || [ 0 ], 0, 59, opts)) != false
end
determine_timezone(z) click to toggle source
# File lib/fugit/cron.rb, line 745
def determine_timezone(z)

  @zone, @timezone = z

  true
end
determine_weekdays(arr, opts) click to toggle source
# File lib/fugit/cron.rb, line 710
def determine_weekdays(arr, opts)

  @weekdays = []

  arr.each do |a, z, sp, sl, ha, mo| # a to z, seperator, slash, hash, and mod
    if sp == :tilde
      a = random(0, 6, a, z, sl, opts)
      z = nil unless sl
    end

    if ha || mo
      @weekdays << [ a, ha || mo ]
    elsif sl
      z ||= (a ? a : 6)
      a ||= 0
      z = z + 7 if a > z
      (a..z).step(sl < 1 ? 1 : sl)
        .each { |i| @weekdays << [ (i > 6) ? i - 7 : i ] }
    elsif z
      z = z + 7 if a > z
      (a..z).each { |i| @weekdays << [ (i > 6) ? i - 7 : i ] }
    elsif a
      @weekdays << [ a ]
    #else
    end
  end

  @weekdays.each { |wd| wd[0] = 0 if wd[0] == 7 } # turn sun7 into sun0
  @weekdays.uniq!
  @weekdays.sort!
  @weekdays = nil if @weekdays.empty?

  true
end
do_determine(key, arr, min, max, opts) click to toggle source
# File lib/fugit/cron.rb, line 673
def do_determine(key, arr, min, max, opts)

  null = false

  r = arr
    .collect { |v|
      expand(min, max, opts, v) }
    .flatten(1)
    .collect { |e|
      return false if e == false
      null = null || e == nil
      (key == :hours && e == 24) ? 0 : e }

  return nil if null
  r.uniq.sort
end
expand(min, max, opts, r) click to toggle source
# File lib/fugit/cron.rb, line 585
def expand(min, max, opts, r)

  sta, edn, sep, sla = r

  #return false if sla && sla > max
    #
    # let it go, "* */24 * * *" and "* */27 * * *" are okay
    # gh-86 and gh-103

  return false if sla && sla < 1

  if sep == :tilde
    sta = random(min, max, sta, edn, sla, opts)
    edn = nil unless sla
  end

  edn = max if sla && edn.nil?

  return nil if sta.nil? && edn.nil? && sla.nil?
  return sta if sta && edn.nil?

  sla = 1 if sla == nil
  sta = min if sta == nil
  edn = max if edn == nil || edn < 0 && sta > 0

  range(min, max, sta, edn, sla)
end
init(original, h, opts) click to toggle source
# File lib/fugit/cron.rb, line 562
def init(original, h, opts)

  return nil unless h

  @original = original
  @cron_s = nil # just to be sure
  @day_and = h[:&]

  valid =
    determine_seconds(h[:sec], opts) &&
    determine_minutes(h[:min], opts) &&
    determine_hours(h[:hou], opts) &&
    determine_monthdays(h[:dom], opts) &&
    determine_months(h[:mon], opts) &&
    determine_weekdays(h[:dow], opts) &&
    determine_timezone(h[:tz])

  return nil unless valid
  return nil unless compact_month_days

  self
end
random(min, max, sta, edn, sla, opts) click to toggle source
# File lib/fugit/cron.rb, line 613
def random(min, max, sta, edn, sla, opts)

  sta = min if sta.nil?
  edn = max if edn.nil?

  random = opts.fetch(:random, true)
  random = Random if random == true
  return sta unless random

  n = edn - sta + 1
  n = max - min + 1 + n if n <= 0
  n = sla if sla && sla < n

  r = sta + random.rand(n)
  r = r + min - max - 1 if r > max
  r
end
range(min, max, sta, edn, sla) click to toggle source
# File lib/fugit/cron.rb, line 631
def range(min, max, sta, edn, sla)

  return [ nil ] if sta == min && edn == max && sla == 1

  fail ArgumentError.new(
    'both start and end must be negative in ' +
    { min: min, max: max, sta: sta, edn: edn, sla: sla }.inspect
  ) if (sta < 0 && edn > 0) || (edn < 0 && sta > 0)

  a = []

  omin, omax = min, max
  min, max = -max, -1 if sta < 0

  cur = sta

  loop do

    a << cur
    break if cur == edn

    cur += 1
    if cur > max
      cur = min
      edn = edn - max - 1 if edn > max
    end

    fail RuntimeError.new(
      "too many loops for " +
      { min: omin, max: omax, sta: sta, edn: edn, sla: sla }.inspect +
      " #range, breaking, " +
      "please fill an issue at https://git.io/fjJC9"
    ) if a.length > 2 * omax
      # there is a #uniq afterwards, hence the 2* for 0-24 and friends
  end

  a.each_with_index
    .select { |e, i| i % sla == 0 }
    .collect(&:first)
    .uniq
end
rough_days() click to toggle source
# File lib/fugit/cron.rb, line 535
def rough_days

  return nil if @weekdays == nil && @monthdays == nil

  months = (@months || (1..12).to_a)

  monthdays = months
    .product(@monthdays || [])
    .collect { |m, d|
      d = 31 + d if d < 0
      (m - 1) * 30 + d } # rough

  weekdays = (@weekdays || [])
    .collect { |d, w|
      w ?
      d + (w - 1) * 7 :
      (0..3).collect { |ww| d + ww * 7 } }
    .flatten
  weekdays = months
    .product(weekdays)
    .collect { |m, d| (m - 1) * 30 + d } # rough

  (monthdays + weekdays).sort
end
weekdays_to_cron_s() click to toggle source
# File lib/fugit/cron.rb, line 752
def weekdays_to_cron_s

  return '*' unless @weekdays

  @weekdays
    .collect { |a|
      if a.length == 1
        a[0].to_s
      elsif a[1].is_a?(Array)
        a11 = a[1][1]
        off = (a11 < 0) ? a11.to_s : (a11 > 0) ? "+#{a11}" : ''
        "#{a[0]}%#{a[1][0]}" + off
      else
        a.collect(&:to_s).join('#')
      end }
    .join(',') +
  (@day_and ? '&' : '')
end