philiprehberger-cron_kit 0.8.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1977410207f5f4175dbc118475df47ab7de96efdf3e04a5bb51f5f7e58eb3999
4
- data.tar.gz: 3ff3ab0609f47dcadf8b6af55d7ddc60439e0691eed2d52874a994c5a139d6e0
3
+ metadata.gz: f5662dedc928394f24b8af5ab47de3d35b4a9847f5c49bc5b10795317048b791
4
+ data.tar.gz: cf11c8cc20f88143e0b55f22f4f2aa8e35384b7ad558ce08eed1033265621bef
5
5
  SHA512:
6
- metadata.gz: 804c1abd96ae6ec6d9ef5905a6d6e0fe040f28658e27348f89158a0aa82358c27f67cc0388ce4b475a3c74986e8ebd7f7f5bb854a9369cf8b416122ede96d34f
7
- data.tar.gz: ca197108c82de369eeae3e64c6115649957de51634d3fa299a6db88c447b510467cd71d9009ced8d35120c2a80b00690643f0ed93c0dc9dda3e00b581be84305
6
+ metadata.gz: 1a0c427360b5546e8e732d4bf20097f8ec8c0265b3a026758282b0c069ea196bb9f7d229dd305e222c248aac60bc9df9e0872d6c069ed95b05fc7d8afec3ec47
7
+ data.tar.gz: 5629f8e7aed24666f3e4b657d6760a6ce1e06ca457f6f4fa1ab146554bb45d17ff64c09f9f49beae50ca0c733f5328ed59b4561390db0fc736a8d07a46c218c0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-07-15
11
+
12
+ ### Added
13
+ - Named month tokens (`JAN`–`DEC`) and weekday tokens (`SUN`–`SAT`) in the month and day-of-week fields, case-insensitive, in single values, lists, and ranges (e.g. `MON-FRI`, `JAN,JUN,DEC`)
14
+ - `7` accepted as an alias for Sunday in the day-of-week field (Vixie/crontab interop)
15
+
16
+ ### Fixed
17
+ - Day-of-month / day-of-week now follow standard (Vixie) cron OR-semantics: when both fields are restricted, a time matches when either matches (e.g. `0 0 13 * 5` fires on the 13th or any Friday); previously the fields were ANDed, causing under-firing. Applies to `#match?`, `#next_at`, `#next_runs`, and `#previous_run`
18
+
19
+ ## [0.8.1] - 2026-06-14
20
+
21
+ ### Changed
22
+ - Added package card image to README
23
+ - Added YARD doc comments to Scheduler public methods
24
+ - Made job-name examples consistent in README
25
+
26
+ ### Fixed
27
+ - Replaced bare RuntimeError raises in Expression parser with Error class
28
+ - Scheduler#every now validates a block is provided at registration time
29
+
10
30
  ## [0.8.0] - 2026-05-13
11
31
 
12
32
  ### Added
@@ -154,7 +174,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
154
174
  - `Philiprehberger::CronKit.parse` convenience method
155
175
  - `Philiprehberger::CronKit.new` convenience method for creating a Scheduler
156
176
 
157
- [Unreleased]: https://github.com/philiprehberger/rb-cron-kit/compare/v0.8.0...HEAD
177
+ [Unreleased]: https://github.com/philiprehberger/rb-cron-kit/compare/v0.9.0...HEAD
178
+ [0.9.0]: https://github.com/philiprehberger/rb-cron-kit/compare/v0.8.1...v0.9.0
179
+ [0.8.1]: https://github.com/philiprehberger/rb-cron-kit/compare/v0.8.0...v0.8.1
158
180
  [0.8.0]: https://github.com/philiprehberger/rb-cron-kit/compare/v0.7.0...v0.8.0
159
181
  [0.7.0]: https://github.com/philiprehberger/rb-cron-kit/compare/v0.6.0...v0.7.0
160
182
  [0.6.0]: https://github.com/philiprehberger/rb-cron-kit/compare/v0.5.1...v0.6.0
data/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-cron_kit.svg)](https://rubygems.org/gems/philiprehberger-cron_kit)
5
5
  [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-cron-kit)](https://github.com/philiprehberger/rb-cron-kit/commits/main)
6
6
 
7
+ ![philiprehberger-cron_kit](https://raw.githubusercontent.com/philiprehberger/rb-cron-kit/main/package-card.webp)
8
+
7
9
  Cron expression parser and scheduler
8
10
 
9
11
  ## Requirements
@@ -183,14 +185,14 @@ scheduler.running_jobs # => 2
183
185
  ```ruby
184
186
  scheduler = Philiprehberger::CronKit.new
185
187
 
186
- scheduler.every("0 9 * * 1-5", name: "morning-report") do
188
+ scheduler.every("0 9 * * 1-5", name: "morning_report") do
187
189
  generate_report
188
190
  end
189
191
 
190
- scheduler.job_names # => ["morning-report"]
191
- scheduler.job?("morning-report") # => true
192
- scheduler.job?("does-not-exist") # => false
193
- scheduler.remove("morning-report")
192
+ scheduler.job_names # => ["morning_report"]
193
+ scheduler.job?("morning_report") # => true
194
+ scheduler.job?("does_not_exist") # => false
195
+ scheduler.remove("morning_report")
194
196
  ```
195
197
 
196
198
  ### Manual Trigger
@@ -199,12 +201,12 @@ Trigger a registered job by name without restarting the scheduler — useful for
199
201
  or operator-driven re-runs.
200
202
 
201
203
  ```ruby
202
- scheduler.every("0 9 * * 1-5", name: :morning_report, timeout: 30) do
204
+ scheduler.every("0 9 * * 1-5", name: "morning_report", timeout: 30) do
203
205
  generate_report
204
206
  end
205
207
 
206
- scheduler.run_now(:morning_report) # => return value of the block
207
- scheduler.run_now(:missing) # raises KeyError
208
+ scheduler.run_now("morning_report") # => return value of the block
209
+ scheduler.run_now("missing") # raises KeyError
208
210
  ```
209
211
 
210
212
  `run_now` honors `timeout:` (raises `Timeout::Error`) and respects `overlap: false`
@@ -214,7 +216,28 @@ scheduler.run_now(:missing) # raises KeyError
214
216
 
215
217
  ```ruby
216
218
  scheduler.next_runs(from: Time.now)
217
- # => { "morning-report" => 2026-03-13 09:00:00 ... }
219
+ # => { "morning_report" => 2026-03-13 09:00:00 ... }
220
+ ```
221
+
222
+ ### Named Tokens and Sunday Interop
223
+
224
+ Three-letter month names (`JAN`–`DEC`) and weekday names (`SUN`–`SAT`) are accepted in the month and day-of-week fields, case-insensitive, in single values, lists, and ranges. In the day-of-week field, `7` is also accepted as an alias for Sunday (`0`), matching Vixie/crontab.
225
+
226
+ ```ruby
227
+ Philiprehberger::CronKit.parse("0 9 * * MON-FRI") # weekdays at 09:00
228
+ Philiprehberger::CronKit.parse("0 0 1 JAN,JUN,DEC *") # 1st of Jan, Jun, Dec
229
+ Philiprehberger::CronKit.parse("* * * * 7") # every minute on Sunday (7 == 0)
230
+ ```
231
+
232
+ ### Day-of-Month / Day-of-Week Matching
233
+
234
+ Following standard (Vixie) cron, when **both** the day-of-month and day-of-week fields are restricted (neither is `*`), a time matches when **either** field matches. When only one is restricted, only that field must match; when both are `*`, the day is unconstrained.
235
+
236
+ ```ruby
237
+ # Fires on the 13th OR any Friday
238
+ expr = Philiprehberger::CronKit.parse("0 0 13 * 5")
239
+ expr.match?(Time.new(2026, 3, 6)) # => true (Friday, not the 13th)
240
+ expr.match?(Time.new(2026, 4, 13)) # => true (13th, not a Friday)
218
241
  ```
219
242
 
220
243
  ### Supported Syntax
@@ -226,6 +249,7 @@ scheduler.next_runs(from: Time.now)
226
249
  | Range | `1-5` | Values from 1 through 5 |
227
250
  | Step | `*/5` | Every 5th value |
228
251
  | List | `1,3,5` | Values 1, 3, and 5 |
252
+ | Name | `MON-FRI` | Month/weekday names |
229
253
  | Alias | `@daily` | Non-standard shorthand |
230
254
 
231
255
  ### Fields
@@ -235,8 +259,8 @@ scheduler.next_runs(from: Time.now)
235
259
  | 1 | Minute | 0-59 |
236
260
  | 2 | Hour | 0-23 |
237
261
  | 3 | Day of month | 1-31 |
238
- | 4 | Month | 1-12 |
239
- | 5 | Day of week | 0-6 |
262
+ | 4 | Month | 1-12 (or `JAN`-`DEC`) |
263
+ | 5 | Day of week | 0-6 (or `SUN`-`SAT`; `7` = Sunday) |
240
264
 
241
265
  ## API
242
266
 
@@ -30,12 +30,14 @@ module Philiprehberger
30
30
  @timezone = timezone
31
31
  @utc_offset = Timezone.utc_offset_for(timezone)
32
32
  @fields = parse_expression(@raw)
33
+ parts = @raw.split(/\s+/)
34
+ @dom_restricted = parts[2] != '*'
35
+ @dow_restricted = parts[4] != '*'
33
36
  end
34
37
 
35
38
  def match?(time)
36
39
  time = coerce_time(time)
37
- values = [time.min, time.hour, time.day, time.month, time.wday]
38
- @fields.each_with_index.all? { |set, i| set.include?(values[i]) }
40
+ values_match?([time.min, time.hour, time.day, time.month, time.wday])
39
41
  end
40
42
 
41
43
  # Return true if any Time in the given enumerable matches the expression.
@@ -95,7 +97,7 @@ module Philiprehberger
95
97
 
96
98
  time += 60
97
99
  end
98
- raise 'no matching time found within 4 years'
100
+ raise Error, 'no matching time found within 4 years'
99
101
  end
100
102
 
101
103
  def scan_backward(time)
@@ -104,12 +106,36 @@ module Philiprehberger
104
106
 
105
107
  time -= 60
106
108
  end
107
- raise 'no matching time found within 4 years'
109
+ raise Error, 'no matching time found within 4 years'
108
110
  end
109
111
 
110
112
  def field_match?(time)
111
- values = [time.min, time.hour, time.day, time.month, time.wday]
112
- @fields.each_with_index.all? { |set, i| set.include?(values[i]) }
113
+ values_match?([time.min, time.hour, time.day, time.month, time.wday])
114
+ end
115
+
116
+ # Minute, hour, and month must always match. Standard (Vixie) cron ORs the
117
+ # day-of-month and day-of-week fields when both are restricted, so
118
+ # `0 0 13 * 5` fires on the 13th OR any Friday.
119
+ def values_match?(values)
120
+ return false unless @fields[0].include?(values[0]) # minute
121
+ return false unless @fields[1].include?(values[1]) # hour
122
+ return false unless @fields[3].include?(values[3]) # month
123
+
124
+ day_of_week_match?(values[2], values[4])
125
+ end
126
+
127
+ def day_of_week_match?(day, wday)
128
+ dom = @fields[2].include?(day)
129
+ dow = @fields[4].include?(wday)
130
+ if @dom_restricted && @dow_restricted
131
+ dom || dow
132
+ elsif @dom_restricted
133
+ dom
134
+ elsif @dow_restricted
135
+ dow
136
+ else
137
+ true
138
+ end
113
139
  end
114
140
  end
115
141
  end
@@ -15,6 +15,20 @@ module Philiprehberger
15
15
 
16
16
  FIELD_NAMES = %w[minute hour day-of-month month day-of-week].freeze
17
17
 
18
+ # Three-letter month names (case-insensitive) mapped to their numeric value.
19
+ MONTH_NAMES = {
20
+ 'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4, 'MAY' => 5, 'JUN' => 6,
21
+ 'JUL' => 7, 'AUG' => 8, 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12
22
+ }.freeze
23
+
24
+ # Three-letter weekday names (case-insensitive) mapped to their numeric value.
25
+ DAY_NAMES = {
26
+ 'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3, 'THU' => 4, 'FRI' => 5, 'SAT' => 6
27
+ }.freeze
28
+
29
+ # Sunday may be written as 7 in the day-of-week field (Vixie cron interop).
30
+ DOW_SUNDAY_ALIAS = 7
31
+
18
32
  def parse_expression(expression)
19
33
  parts = expression.split(/\s+/)
20
34
 
@@ -28,12 +42,42 @@ module Philiprehberger
28
42
  private
29
43
 
30
44
  def parse_field(field, range, name)
31
- values = field.split(',').flat_map { |token| parse_token(token, range, name) }
45
+ normalized = normalize_names(field, name)
46
+ parse_range = parse_range_for(name, range)
47
+ values = normalized.split(',').flat_map { |token| parse_token(token, parse_range, name) }
32
48
  raise ParseError, "empty field for #{name}" if values.empty?
33
49
 
50
+ values = values.map { |value| value == DOW_SUNDAY_ALIAS ? 0 : value } if name == 'day-of-week'
34
51
  values.uniq.sort
35
52
  end
36
53
 
54
+ # Replace three-letter month/weekday names (case-insensitive) with their
55
+ # numeric value before numeric parsing. Names are supported in single
56
+ # values, lists, and ranges (e.g. MON-FRI, JAN,JUN,DEC).
57
+ def normalize_names(field, name)
58
+ table = name_table_for(name)
59
+ return field unless table
60
+
61
+ field.gsub(/[A-Za-z]+/) do |word|
62
+ table.fetch(word.upcase) do
63
+ raise ParseError, "invalid name #{word.inspect} in #{name} field"
64
+ end
65
+ end
66
+ end
67
+
68
+ def name_table_for(name)
69
+ case name
70
+ when 'month' then MONTH_NAMES
71
+ when 'day-of-week' then DAY_NAMES
72
+ end
73
+ end
74
+
75
+ # The day-of-week field accepts 7 as an alias for Sunday, so parsing must
76
+ # tolerate 7 (mapped to 0 afterwards) even though the field range is 0..6.
77
+ def parse_range_for(name, range)
78
+ name == 'day-of-week' ? (range.min..DOW_SUNDAY_ALIAS) : range
79
+ end
80
+
37
81
  def parse_token(token, range, name)
38
82
  try_wildcard(token, range) ||
39
83
  try_step(token, range, name) ||
@@ -18,17 +18,43 @@ module Philiprehberger
18
18
  @job_threads = {}
19
19
  end
20
20
 
21
+ # Register a callback invoked when a job raises an error.
22
+ #
23
+ # Called with the failing +Job+ and the +StandardError+ raised. With no
24
+ # block, returns the currently registered callback (or +nil+).
25
+ #
26
+ # @yieldparam job [Job] the job that failed
27
+ # @yieldparam error [StandardError] the error raised by the job's block
28
+ # @return [Proc, nil] the registered callback when called without a block
21
29
  def on_error(&block)
22
30
  return @on_error unless block
23
31
 
24
32
  @on_error = block
25
33
  end
26
34
 
35
+ # Number of currently executing job threads.
36
+ #
37
+ # @return [Integer] count of live job threads
27
38
  def running_jobs
28
39
  @mutex.synchronize { @running_threads.count(&:alive?) }
29
40
  end
30
41
 
42
+ # Register a cron job to be executed when +expression+ matches.
43
+ #
44
+ # Accepts either a cron expression string or a pre-built +Expression+.
45
+ # Raises +ArgumentError+ immediately if no block is provided, surfacing
46
+ # configuration mistakes at registration time rather than at tick time.
47
+ #
48
+ # @param expression [String, Expression] cron expression or pre-built Expression
49
+ # @param name [Symbol, String, nil] optional job name for lookup/removal/triggering
50
+ # @param timeout [Numeric, nil] kill the job if it exceeds this many seconds
51
+ # @param overlap [Boolean] when +false+, skip the tick if a previous run is still active
52
+ # @yieldparam time [Time] the tick time the job fires at
53
+ # @return [self]
54
+ # @raise [ArgumentError] if no block is provided
31
55
  def every(expression, name: nil, timeout: nil, overlap: true, &block)
56
+ raise ArgumentError, 'block required' unless block
57
+
32
58
  expr = expression.is_a?(Expression) ? expression : Expression.new(expression)
33
59
 
34
60
  @mutex.synchronize do
@@ -38,6 +64,11 @@ module Philiprehberger
38
64
  self
39
65
  end
40
66
 
67
+ # Names of all registered named jobs.
68
+ #
69
+ # Anonymous jobs (registered without +name:+) are omitted.
70
+ #
71
+ # @return [Array<Symbol, String>] list of registered job names
41
72
  def job_names
42
73
  @mutex.synchronize { @jobs.map(&:name).compact }
43
74
  end
@@ -54,6 +85,10 @@ module Philiprehberger
54
85
  @mutex.synchronize { @jobs.any? { |j| j.name == name } }
55
86
  end
56
87
 
88
+ # Remove a registered job by name.
89
+ #
90
+ # @param name [Symbol, String] the job name to remove
91
+ # @return [Boolean] +true+ if a matching job was removed, +false+ otherwise
57
92
  def remove(name)
58
93
  @mutex.synchronize do
59
94
  initial_size = @jobs.size
@@ -80,6 +115,12 @@ module Philiprehberger
80
115
  execute_now(job)
81
116
  end
82
117
 
118
+ # Upcoming run times for every named job.
119
+ #
120
+ # Anonymous jobs are omitted.
121
+ #
122
+ # @param from [Time] starting point for the lookahead (defaults to +Time.now+)
123
+ # @return [Hash{Symbol, String => Time}] map of job name to the next matching time
83
124
  def next_runs(from: Time.now)
84
125
  jobs = @mutex.synchronize { @jobs.dup }
85
126
 
@@ -90,6 +131,11 @@ module Philiprehberger
90
131
  end
91
132
  end
92
133
 
134
+ # Start the scheduler loop in a background thread.
135
+ #
136
+ # Idempotent — calling +start+ on an already-running scheduler is a no-op.
137
+ #
138
+ # @return [self]
93
139
  def start
94
140
  @mutex.synchronize do
95
141
  return self if @running
@@ -103,6 +149,9 @@ module Philiprehberger
103
149
  self
104
150
  end
105
151
 
152
+ # Stop the scheduler loop and wait briefly for the background thread to exit.
153
+ #
154
+ # @return [self]
106
155
  def stop
107
156
  @mutex.synchronize { @running = false }
108
157
  @thread&.join(5)
@@ -111,6 +160,9 @@ module Philiprehberger
111
160
  self
112
161
  end
113
162
 
163
+ # Whether the scheduler loop is currently running.
164
+ #
165
+ # @return [Boolean]
114
166
  def running?
115
167
  @mutex.synchronize { @running }
116
168
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module CronKit
5
- VERSION = '0.8.0'
5
+ VERSION = '0.9.0'
6
6
  end
7
7
  end
@@ -10,6 +10,8 @@ require_relative 'cron_kit/scheduler'
10
10
 
11
11
  module Philiprehberger
12
12
  module CronKit
13
+ class Error < StandardError; end
14
+
13
15
  # Parse a 5-field cron expression and return an Expression instance.
14
16
  # Supports non-standard aliases (@hourly, @daily, etc.) and optional timezone.
15
17
  def self.parse(expression, timezone: nil)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-cron_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-14 00:00:00.000000000 Z
11
+ date: 2026-07-16 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A zero-dependency Ruby gem for parsing 5-field cron expressions and running
14
14
  an in-process scheduler. Supports wildcards, ranges, steps, and lists.