hron 0.6.1 → 2.0.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.
data/lib/hron/parts.rb ADDED
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require_relative "ast"
5
+ require_relative "error"
6
+ require_relative "display"
7
+ require_relative "parser"
8
+
9
+ module Hron
10
+ # The checks of spec/README.md, "Schedules built in code", in its order. A part of the wrong
11
+ # Ruby type is a TypeError, a usage error rather than a HronError. Names are Symbols, so a
12
+ # name of another type is a TypeError too; the AST's unions (expression, day filter, ...)
13
+ # have no Ruby type of their own, so any value outside one is an unknown value of its kind.
14
+ # checked builds every part anew as its Hron class, so a part of a subclass gives a schedule
15
+ # equal to the parsed one.
16
+ module Parts
17
+ INTERVAL_MAX = 2_147_483_647
18
+
19
+ module_function
20
+
21
+ def checked(data)
22
+ raise TypeError, "data must be a Hron::ScheduleData, not #{data.class}" unless data.is_a?(ScheduleData)
23
+
24
+ expr = check_expression(data.expression)
25
+ except = list(data.except, "except").map { |exception| check_exception(exception) }
26
+ until_spec = data.until.nil? ? nil : check_until(data.until)
27
+ starting = data.starting.nil? ? nil : check_iso_date(string(data.starting, "starting"))
28
+ during = list(data.during, "during").map { |month| check_name(month, MonthName::ALL, "month") }
29
+ timezone = data.timezone.nil? ? nil : check_timezone(string(data.timezone, "timezone"))
30
+ if until_spec.is_a?(NamedUntil) && starting.nil?
31
+ raise error("until #{until_spec.month} #{until_spec.day} has no year: add a starting date, or use an ISO date")
32
+ end
33
+ ScheduleData.new(expression: expr, timezone:, except:, until: until_spec, starting:, during:)
34
+ end
35
+
36
+ def check_expression(expr)
37
+ case expr
38
+ when IntervalRepeat
39
+ interval = check_interval(expr.interval)
40
+ unit = check_name(expr.unit, IntervalUnit::ALL, "interval unit")
41
+ from = check_time(expr.from_time)
42
+ to = check_time(expr.to_time)
43
+ if minute_of_day(from) > minute_of_day(to)
44
+ raise error("time window must not run backwards: #{from} to #{to} (a window cannot cross midnight)")
45
+ end
46
+ day_filter = expr.day_filter.nil? ? nil : check_day_filter(expr.day_filter)
47
+ IntervalRepeat.new(interval, unit, from, to, day_filter)
48
+ when DayRepeat
49
+ interval = check_interval(expr.interval)
50
+ # `every 2 days` has no place for a day filter, so other days would not survive display.
51
+ raise error("days must be every day when the interval is above 1") if interval > 1 && !expr.days.is_a?(DayFilterEvery)
52
+
53
+ DayRepeat.new(interval, check_day_filter(expr.days), check_times(expr.times))
54
+ when WeekRepeat
55
+ interval = check_interval(expr.interval)
56
+ WeekRepeat.new(interval, check_weekdays(expr.days), check_times(expr.times))
57
+ when MonthRepeat
58
+ interval = check_interval(expr.interval)
59
+ MonthRepeat.new(interval, check_month_target(expr.target), check_times(expr.times))
60
+ when SingleDateExpr
61
+ SingleDateExpr.new(check_date(expr.date), check_times(expr.times))
62
+ when YearRepeat
63
+ interval = check_interval(expr.interval)
64
+ YearRepeat.new(interval, check_year_target(expr.target), check_times(expr.times))
65
+ else
66
+ raise unknown("expression", expr)
67
+ end
68
+ end
69
+
70
+ def check_interval(interval)
71
+ integer(interval, "interval")
72
+ raise error("interval must be 1-#{INTERVAL_MAX}, got #{interval}") unless interval.between?(1, INTERVAL_MAX)
73
+
74
+ interval
75
+ end
76
+
77
+ def check_times(times)
78
+ raise error("times must not be empty") if list(times, "times").empty?
79
+
80
+ times.map { |time| check_time(time) }
81
+ end
82
+
83
+ def check_time(time)
84
+ raise TypeError, "a time must be a Hron::TimeOfDay, not #{time.class}" unless time.is_a?(TimeOfDay)
85
+
86
+ integer(time.hour, "hour")
87
+ integer(time.minute, "minute")
88
+ raise error("time must be 00:00-23:59, got #{time}") unless time.hour.between?(0, 23) && time.minute.between?(0, 59)
89
+
90
+ TimeOfDay.new(time.hour, time.minute)
91
+ end
92
+
93
+ def minute_of_day(time)
94
+ (time.hour * 60) + time.minute
95
+ end
96
+
97
+ def check_day_filter(filter)
98
+ case filter
99
+ when DayFilterEvery then DayFilterEvery.new
100
+ when DayFilterWeekday then DayFilterWeekday.new
101
+ when DayFilterWeekend then DayFilterWeekend.new
102
+ when DayFilterDays then DayFilterDays.new(check_weekdays(filter.days))
103
+ else raise unknown("day filter", filter)
104
+ end
105
+ end
106
+
107
+ def check_weekdays(days)
108
+ raise error("days must not be empty") if list(days, "days").empty?
109
+
110
+ days.map { |day| check_name(day, Weekday::ALL, "weekday") }
111
+ end
112
+
113
+ def check_month_target(target)
114
+ case target
115
+ when DaysTarget
116
+ raise error("days must not be empty") if list(target.specs, "days").empty?
117
+
118
+ DaysTarget.new(target.specs.map { |spec| check_day_spec(spec) })
119
+ when LastDayTarget then LastDayTarget.new
120
+ when LastWeekdayTarget then LastWeekdayTarget.new
121
+ when NearestWeekdayTarget
122
+ direction = target.direction.nil? ? nil : check_name(target.direction, NearestDirection::ALL, "direction")
123
+ check_day(target.day, suffixed: true)
124
+ NearestWeekdayTarget.new(target.day, direction)
125
+ when OrdinalWeekdayTarget
126
+ ordinal = check_name(target.ordinal, OrdinalPosition::ALL, "ordinal")
127
+ OrdinalWeekdayTarget.new(ordinal, check_name(target.weekday, Weekday::ALL, "weekday"))
128
+ else
129
+ raise unknown("month target", target)
130
+ end
131
+ end
132
+
133
+ def check_day_spec(spec)
134
+ case spec
135
+ when SingleDay
136
+ check_day(spec.day, suffixed: true)
137
+ SingleDay.new(spec.day)
138
+ when DayRange
139
+ start = check_day(spec.start, suffixed: true)
140
+ last = check_day(spec.end_day, suffixed: true)
141
+ raise error("day range must not run backwards: #{start} to #{last}") if spec.start > spec.end_day
142
+
143
+ DayRange.new(spec.start, spec.end_day)
144
+ else
145
+ raise unknown("day spec", spec)
146
+ end
147
+ end
148
+
149
+ def check_year_target(target)
150
+ case target
151
+ when YearDateTarget then YearDateTarget.new(*check_named_date(target.month, target.day))
152
+ when YearOrdinalWeekdayTarget
153
+ ordinal = check_name(target.ordinal, OrdinalPosition::ALL, "ordinal")
154
+ weekday = check_name(target.weekday, Weekday::ALL, "weekday")
155
+ YearOrdinalWeekdayTarget.new(ordinal, weekday, check_name(target.month, MonthName::ALL, "month"))
156
+ when YearDayOfMonthTarget
157
+ month = check_name(target.month, MonthName::ALL, "month")
158
+ shown = check_day(target.day, suffixed: true)
159
+ check_day_in_month(target.day, month, shown)
160
+ YearDayOfMonthTarget.new(target.day, month)
161
+ when YearLastWeekdayTarget then YearLastWeekdayTarget.new(check_name(target.month, MonthName::ALL, "month"))
162
+ else raise unknown("year target", target)
163
+ end
164
+ end
165
+
166
+ def check_date(date)
167
+ case date
168
+ when NamedDate then NamedDate.new(*check_named_date(date.month, date.day))
169
+ when IsoDate then IsoDate.new(check_iso_date(string(date.date, "date")))
170
+ else raise unknown("date", date)
171
+ end
172
+ end
173
+
174
+ def check_exception(exception)
175
+ case exception
176
+ when NamedException then NamedException.new(*check_named_date(exception.month, exception.day))
177
+ when IsoException then IsoException.new(check_iso_date(string(exception.date, "date")))
178
+ else raise unknown("exception", exception)
179
+ end
180
+ end
181
+
182
+ def check_until(until_spec)
183
+ case until_spec
184
+ when NamedUntil then NamedUntil.new(*check_named_date(until_spec.month, until_spec.day))
185
+ when IsoUntil then IsoUntil.new(check_iso_date(string(until_spec.date, "date")))
186
+ else raise unknown("until", until_spec)
187
+ end
188
+ end
189
+
190
+ def check_named_date(month, day)
191
+ check_name(month, MonthName::ALL, "month")
192
+ shown = check_day(day, suffixed: false)
193
+ check_day_in_month(day, month, shown)
194
+ [month, day]
195
+ end
196
+
197
+ def check_day(day, suffixed:)
198
+ integer(day, "day")
199
+ shown = suffixed ? "#{day}#{Display.ordinal_suffix(day)}" : day.to_s
200
+ raise error("day must be 1-31, got #{shown}") unless day.between?(1, 31)
201
+
202
+ shown
203
+ end
204
+
205
+ def check_day_in_month(day, month, shown)
206
+ max = MonthName.max_day(month)
207
+ raise error("day must be 1-#{max} for #{month}, got #{shown}") if day > max
208
+ end
209
+
210
+ # Date.iso8601 alone would also read `20260206`, `+002026-02-06` and fullwidth digits. The
211
+ # encoding must be ASCII-compatible, or ten bytes that read as a date are other characters.
212
+ def check_iso_date(date)
213
+ if date.encoding.ascii_compatible? && date.b.match?(/\A\d{4}-\d{2}-\d{2}\z/)
214
+ year, month, day = date.b.split("-").map(&:to_i)
215
+ return date if year >= 1 && Date.valid_date?(year, month, day, Date::GREGORIAN)
216
+ end
217
+
218
+ raise error("date must be a calendar date from 0001-01-01 to 9999-12-31, got #{Utf8.convert(date)}")
219
+ end
220
+
221
+ def check_timezone(name)
222
+ Parser.iana_timezone(name) or
223
+ raise error("timezone must be UTC or an Area/Location name such as America/New_York, got #{Utf8.convert(name)}")
224
+ end
225
+
226
+ def check_name(value, names, kind)
227
+ raise TypeError, "#{kind} must be a Symbol, not #{value.class}" unless value.is_a?(Symbol)
228
+ raise unknown(kind, value) unless names.include?(value)
229
+
230
+ value
231
+ end
232
+
233
+ def integer(value, what)
234
+ raise TypeError, "#{what} must be an Integer, not #{value.class}" unless value.is_a?(Integer)
235
+ end
236
+
237
+ def string(value, what)
238
+ raise TypeError, "#{what} must be a String, not #{value.class}" unless value.is_a?(String)
239
+
240
+ String.new(value)
241
+ end
242
+
243
+ def list(value, what)
244
+ raise TypeError, "#{what} must be an Array, not #{value.class}" unless value.is_a?(Array)
245
+
246
+ value
247
+ end
248
+
249
+ def unknown(kind, value)
250
+ error("unknown #{kind} #{value.inspect}")
251
+ end
252
+
253
+ def error(message)
254
+ HronError.eval(message)
255
+ end
256
+ end
257
+ end
data/lib/hron/schedule.rb CHANGED
@@ -4,65 +4,96 @@ require_relative "parser"
4
4
  require_relative "evaluator"
5
5
  require_relative "display"
6
6
  require_relative "cron"
7
+ require_relative "parts"
7
8
 
8
9
  module Hron
9
- # Main Schedule class - the primary public API for hron
10
+ # Each method that takes a time takes a Time in any zone and reads only its instant, and
11
+ # raises TypeError for anything else. Each Time returned is in the schedule's timezone, with
12
+ # that TZInfo::Timezone as its zone, or in UTC when the schedule has none.
10
13
  class Schedule
14
+ # The frozen ScheduleData this schedule was built from, with the timezone in its IANA
15
+ # capitalization.
11
16
  attr_reader :data
12
17
 
18
+ # Builds a schedule from a ScheduleData, checked by the rules parse applies. Raises HronError
19
+ # of kind :eval for the first part that breaks one, in the order of spec/README.md,
20
+ # "Schedules built in code", and TypeError for a part of the wrong type. Keeps a frozen
21
+ # copy, so later changes to data's lists and strings do not change the schedule.
13
22
  def initialize(data)
14
- @data = data
23
+ @data = Ractor.make_shareable(Parts.checked(data))
15
24
  end
16
25
 
17
- # Parse a hron expression and return a Schedule
26
+ # For parse and from_cron, whose parts already keep every rule and are their own.
27
+ def self.from_valid(data)
28
+ allocate.tap { |schedule| schedule.instance_variable_set(:@data, Ractor.make_shareable(data)) }
29
+ end
30
+ private_class_method :from_valid
31
+
32
+ # Raises HronError if the expression is invalid, and TypeError unless input is a String.
18
33
  def self.parse(input)
19
- new(Hron.parse(input))
34
+ require_string(input, "input")
35
+ from_valid(Parser.parse(input))
20
36
  end
21
37
 
22
- # Parse a cron expression and return a Schedule
38
+ # Converts a 5-field cron expression to a Schedule that fires at the same times. Raises
39
+ # HronError of kind :cron when it is not valid cron or has no exact hron equivalent, and
40
+ # TypeError unless cron_expr is a String.
23
41
  def self.from_cron(cron_expr)
24
- new(Cron.from_cron(cron_expr))
42
+ require_string(cron_expr, "cron_expr")
43
+ from_valid(Cron.from_cron(cron_expr))
25
44
  end
26
45
 
27
- # Validate a hron expression without raising an error
46
+ # True when parse accepts input, false when it raises HronError. Raises TypeError unless
47
+ # input is a String.
28
48
  def self.validate(input)
29
- Hron.parse(input)
49
+ require_string(input, "input")
50
+ Parser.parse(input)
30
51
  true
31
52
  rescue HronError
32
53
  false
33
54
  end
34
55
 
35
- # Get the next occurrence from the given time
56
+ # A usage error, never a HronError or false (spec/README.md, "Timestamps and counts").
57
+ def self.require_string(value, name)
58
+ raise TypeError, "#{name} must be a String, not #{value.class}" unless value.is_a?(String)
59
+ end
60
+ private_class_method :require_string
61
+
62
+ # Returns the next occurrence strictly after now, or nil if there is none.
36
63
  def next_from(now)
37
64
  Evaluator.next_from(@data, now)
38
65
  end
39
66
 
40
- # Get the next N occurrences from the given time
67
+ # Returns an Array of up to n occurrences strictly after now, empty when n <= 0. Raises
68
+ # TypeError unless n is an Integer.
41
69
  def next_n_from(now, n)
42
70
  Evaluator.next_n_from(@data, now, n)
43
71
  end
44
72
 
45
- # Get the most recent occurrence strictly before the given time
73
+ # Returns the most recent occurrence strictly before now, or nil if there is none.
46
74
  def previous_from(now)
47
75
  Evaluator.previous_from(@data, now)
48
76
  end
49
77
 
50
- # Check if the schedule matches the given datetime
78
+ # True when the minute containing dt, on the schedule's wall clock, is an occurrence.
51
79
  def matches(dt)
52
80
  Evaluator.matches(@data, dt)
53
81
  end
54
82
 
55
- # Returns a lazy Enumerator of occurrences starting after `from`
83
+ # Returns a lazy Enumerator of occurrences strictly after from. Unbounded for repeating
84
+ # schedules unless an until clause or the end of the supported range ends them.
56
85
  def occurrences(from)
57
86
  Evaluator.occurrences(@data, from)
58
87
  end
59
88
 
60
- # Returns a lazy Enumerator of occurrences where from < occurrence <= to
89
+ # Returns a lazy Enumerator of occurrences where from < occurrence <= to, empty when either
90
+ # bound is outside the supported range.
61
91
  def between(from, to)
62
92
  Evaluator.between(@data, from, to)
63
93
  end
64
94
 
65
- # Convert to 5-field cron expression
95
+ # Converts to a 5-field cron expression that fires at the same times, leaving out the
96
+ # timezone. Raises HronError of kind :cron when no cron does.
66
97
  def to_cron
67
98
  Cron.to_cron(@data)
68
99
  end
@@ -72,19 +103,50 @@ module Hron
72
103
  Display.display(@data)
73
104
  end
74
105
 
75
- # Get inspect representation
76
106
  def inspect
77
107
  "Schedule(\"#{self}\")"
78
108
  end
79
109
 
80
- # Get the timezone
110
+ # Schedules are equal when their parts are.
111
+ def ==(other)
112
+ other.is_a?(Schedule) && data == other.data
113
+ end
114
+
115
+ def eql?(other)
116
+ other.is_a?(Schedule) && data.eql?(other.data)
117
+ end
118
+
119
+ def hash
120
+ data.hash
121
+ end
122
+
123
+ # Returns the IANA timezone name with its canonical capitalization, or nil if none was given.
81
124
  def timezone
82
125
  @data.timezone
83
126
  end
84
127
 
85
- # Get the schedule expression
86
128
  def expression
87
- @data.expr
129
+ @data.expression
130
+ end
131
+
132
+ # The except dates, in the order written; empty without an except clause.
133
+ def except
134
+ @data.except
135
+ end
136
+
137
+ # An IsoUntil or NamedUntil, or nil without an until clause.
138
+ def until
139
+ @data.until
140
+ end
141
+
142
+ # The starting date as a YYYY-MM-DD String, or nil without a starting clause.
143
+ def starting
144
+ @data.starting
145
+ end
146
+
147
+ # The during months as Symbols, in the order written; empty without a during clause.
148
+ def during
149
+ @data.during
88
150
  end
89
151
  end
90
152
  end
data/lib/hron/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hron
4
- VERSION = "0.6.1"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/hron.rb CHANGED
@@ -8,21 +8,31 @@ require_relative "hron/parser"
8
8
  require_relative "hron/evaluator"
9
9
  require_relative "hron/display"
10
10
  require_relative "hron/cron"
11
+ require_relative "hron/parts"
11
12
  require_relative "hron/schedule"
12
13
 
13
14
  module Hron
15
+ # Parser, Evaluator, Display and Cron take or return a schedule's parts without the checks
16
+ # of Schedule.new, Parts is those checks, and Lexer gives Parser its tokens, so only code
17
+ # inside Hron uses them (spec/README.md, "Schedules built in code").
18
+ private_constant :Lexer, :Parser, :Evaluator, :Display, :Cron, :Parts
19
+
14
20
  class << self
15
- # Parse a hron expression and return a Schedule
21
+ # Parses a hron expression into a Schedule. Raises HronError if it is invalid, and TypeError
22
+ # unless input is a String.
16
23
  def parse_schedule(input)
17
24
  Schedule.parse(input)
18
25
  end
19
26
 
20
- # Validate a hron expression without raising an error
27
+ # True when parse accepts input, false when it raises HronError. Raises TypeError unless
28
+ # input is a String.
21
29
  def validate(input)
22
30
  Schedule.validate(input)
23
31
  end
24
32
 
25
- # Parse from a cron expression
33
+ # Converts a 5-field cron expression to a Schedule that fires at the same times. Raises
34
+ # HronError of kind :cron when it is not valid cron or has no exact hron equivalent, and
35
+ # TypeError unless cron_expr is a String.
26
36
  def from_cron(cron_expr)
27
37
  Schedule.from_cron(cron_expr)
28
38
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hron
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Prasanna Venkataraman
@@ -23,53 +23,11 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.0'
26
- - !ruby/object:Gem::Dependency
27
- name: minitest
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '5.20'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '5.20'
40
- - !ruby/object:Gem::Dependency
41
- name: rake
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '13.0'
47
- type: :development
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '13.0'
54
- - !ruby/object:Gem::Dependency
55
- name: standard
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '1.43'
61
- type: :development
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '1.43'
68
26
  description: hron (human-readable cron) is a scheduling expression language that is
69
- designed to be easy to read, write, and understand. It is a superset of cron, meaning
70
- any valid cron expression can be converted to and from hron.
27
+ designed to be easy to read, write, and understand. It converts to and from cron
28
+ exactly where both can express a schedule, and expresses schedules cron cannot.
71
29
  email:
72
- - prasrvenkat@gmail.com
30
+ - pras@simpllyf.io
73
31
  executables: []
74
32
  extensions: []
75
33
  extra_rdoc_files: []
@@ -82,18 +40,21 @@ files:
82
40
  - lib/hron/cron.rb
83
41
  - lib/hron/display.rb
84
42
  - lib/hron/error.rb
43
+ - lib/hron/eval/calendar.rb
44
+ - lib/hron/eval/wall_clock.rb
85
45
  - lib/hron/evaluator.rb
86
46
  - lib/hron/lexer.rb
87
47
  - lib/hron/parser.rb
48
+ - lib/hron/parts.rb
88
49
  - lib/hron/schedule.rb
89
50
  - lib/hron/version.rb
90
- homepage: https://github.com/prasrvenkat/hron
51
+ homepage: https://hron.io
91
52
  licenses:
92
53
  - MIT
93
54
  metadata:
94
- homepage_uri: https://github.com/prasrvenkat/hron
95
- source_code_uri: https://github.com/prasrvenkat/hron
96
- changelog_uri: https://github.com/prasrvenkat/hron/releases
55
+ homepage_uri: https://hron.io
56
+ source_code_uri: https://github.com/simpllyf/hron
57
+ changelog_uri: https://github.com/simpllyf/hron/releases
97
58
  rubygems_mfa_required: 'true'
98
59
  rdoc_options: []
99
60
  require_paths:
@@ -109,8 +70,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
70
  - !ruby/object:Gem::Version
110
71
  version: '0'
111
72
  requirements: []
112
- rubygems_version: 4.0.3
73
+ rubygems_version: 4.0.20
113
74
  specification_version: 4
114
- summary: Human-readable cron — a scheduling expression language that is a superset
115
- of cron
75
+ summary: Human-readable cron — scheduling expressions that read like English and convert
76
+ to and from cron
116
77
  test_files: []