procrastinator 0.9.0 → 1.0.0.pre.rc2
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 +5 -5
- data/.gitignore +6 -1
- data/.rubocop.yml +20 -1
- data/README.md +327 -333
- data/RELEASE_NOTES.md +44 -0
- data/lib/procrastinator/config.rb +93 -129
- data/lib/procrastinator/logged_task.rb +50 -0
- data/lib/procrastinator/queue.rb +168 -12
- data/lib/procrastinator/queue_worker.rb +52 -97
- data/lib/procrastinator/rake/daemon_tasks.rb +54 -0
- data/lib/procrastinator/rake/tasks.rb +3 -0
- data/lib/procrastinator/scheduler.rb +299 -77
- data/lib/procrastinator/task.rb +46 -28
- data/lib/procrastinator/task_meta_data.rb +96 -52
- data/lib/procrastinator/task_store/file_transaction.rb +76 -0
- data/lib/procrastinator/task_store/simple_comma_store.rb +161 -0
- data/lib/procrastinator/test/mocks.rb +35 -0
- data/lib/procrastinator/version.rb +1 -1
- data/lib/procrastinator.rb +9 -24
- data/procrastinator.gemspec +13 -9
- metadata +43 -26
- data/lib/procrastinator/loaders/csv_loader.rb +0 -107
- data/lib/procrastinator/queue_manager.rb +0 -201
- data/lib/procrastinator/task_worker.rb +0 -100
- data/lib/rake/procrastinator_task.rb +0 -34
@@ -1,10 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'time'
|
4
|
+
|
3
5
|
module Procrastinator
|
4
6
|
# TaskMetaData objects are State Patterns that record information about the work done on a particular task.
|
5
7
|
#
|
6
|
-
# It contains the specific information needed to run a task instance. Users define a task class, which
|
7
|
-
# the "how" of a task and TaskMetaData represents the "what" and "when".
|
8
|
+
# It contains the specific information needed to run a task instance. Users define a task handler class, which
|
9
|
+
# describes the "how" of a task and TaskMetaData represents the "what" and "when".
|
8
10
|
#
|
9
11
|
# It contains task-specific data, timing information, and error records.
|
10
12
|
#
|
@@ -27,64 +29,63 @@ module Procrastinator
|
|
27
29
|
# @!attribute [r] :last_fail_at
|
28
30
|
# @return [Integer] Linux epoch timestamp of when the last_error was recorded
|
29
31
|
# @!attribute [r] :data
|
30
|
-
# @return [String]
|
32
|
+
# @return [String] App-provided JSON data
|
31
33
|
class TaskMetaData
|
32
34
|
# These are the attributes expected to be in the persistence mechanism
|
33
35
|
EXPECTED_DATA = [:id, :run_at, :initial_run_at, :expire_at, :attempts, :last_error, :last_fail_at, :data].freeze
|
34
36
|
|
35
|
-
attr_reader(*EXPECTED_DATA)
|
37
|
+
attr_reader(*EXPECTED_DATA, :queue)
|
36
38
|
|
37
|
-
def initialize(id: nil,
|
38
|
-
run_at: nil,
|
39
|
-
|
40
|
-
expire_at: nil,
|
41
|
-
attempts: 0,
|
42
|
-
last_error: nil,
|
43
|
-
last_fail_at: nil,
|
44
|
-
data: nil)
|
39
|
+
def initialize(id: nil, queue: nil, data: nil,
|
40
|
+
run_at: nil, initial_run_at: nil, expire_at: nil,
|
41
|
+
attempts: 0, last_error: nil, last_fail_at: nil)
|
45
42
|
@id = id
|
46
|
-
@
|
47
|
-
@
|
48
|
-
@
|
49
|
-
@
|
43
|
+
@queue = queue || raise(ArgumentError, 'queue cannot be nil')
|
44
|
+
@run_at = get_time(run_at)
|
45
|
+
@initial_run_at = get_time(initial_run_at) || @run_at
|
46
|
+
@expire_at = get_time(expire_at)
|
47
|
+
@attempts = (attempts || 0).to_i
|
50
48
|
@last_error = last_error
|
51
|
-
@last_fail_at = last_fail_at
|
52
|
-
@data = data ?
|
53
|
-
end
|
54
|
-
|
55
|
-
def init_task(queue)
|
56
|
-
@data ? queue.task_class.new(@data) : queue.task_class.new
|
49
|
+
@last_fail_at = get_time(last_fail_at)
|
50
|
+
@data = data ? JSON.parse(data, symbolize_names: true) : nil
|
57
51
|
end
|
58
52
|
|
59
53
|
def add_attempt
|
60
|
-
|
61
|
-
end
|
54
|
+
raise Task::AttemptsExhaustedError unless attempts_left?
|
62
55
|
|
63
|
-
|
64
|
-
@last_error = nil
|
65
|
-
@last_fail_at = nil
|
56
|
+
@attempts += 1
|
66
57
|
end
|
67
58
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
59
|
+
# Records a failure on this task
|
60
|
+
#
|
61
|
+
# @param error [StandardError] The error to record
|
62
|
+
def failure(error)
|
63
|
+
@last_fail_at = Time.now
|
64
|
+
@last_error = %[Task failed: #{ error.message }\n#{ error.backtrace&.join("\n") }]
|
65
|
+
|
66
|
+
if retryable?
|
67
|
+
reschedule
|
68
|
+
:fail
|
69
|
+
else
|
70
|
+
@run_at = nil
|
71
|
+
:final_fail
|
72
|
+
end
|
72
73
|
end
|
73
74
|
|
74
|
-
def
|
75
|
-
|
75
|
+
def retryable?
|
76
|
+
attempts_left? && !expired?
|
76
77
|
end
|
77
78
|
|
78
79
|
def expired?
|
79
|
-
!@expire_at.nil? && Time.now
|
80
|
+
!@expire_at.nil? && @expire_at < Time.now
|
80
81
|
end
|
81
82
|
|
82
|
-
def
|
83
|
-
|
83
|
+
def attempts_left?
|
84
|
+
@queue.max_attempts.nil? || @attempts < @queue.max_attempts
|
84
85
|
end
|
85
86
|
|
86
87
|
def runnable?
|
87
|
-
|
88
|
+
!@run_at.nil? && @run_at <= Time.now
|
88
89
|
end
|
89
90
|
|
90
91
|
def successful?
|
@@ -93,26 +94,32 @@ module Procrastinator
|
|
93
94
|
!expired? && @last_error.nil? && @last_fail_at.nil?
|
94
95
|
end
|
95
96
|
|
96
|
-
#
|
97
|
-
#
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
97
|
+
# Updates the run and/or expiry time. If neither is provided, will reschedule based on the rescheduling
|
98
|
+
# calculation algorithm.
|
99
|
+
#
|
100
|
+
# @param run_at - the new time to run this task
|
101
|
+
# @param expire_at - the new time to expire this task
|
102
|
+
def reschedule(run_at: nil, expire_at: nil)
|
103
|
+
validate_run_at(run_at, expire_at)
|
103
104
|
|
104
|
-
|
105
|
+
@expire_at = expire_at if expire_at
|
105
106
|
|
106
|
-
|
107
|
-
|
108
|
-
|
107
|
+
if run_at
|
108
|
+
@run_at = @initial_run_at = get_time(run_at)
|
109
|
+
clear_fails
|
110
|
+
@attempts = 0
|
111
|
+
end
|
109
112
|
|
110
|
-
|
111
|
-
|
113
|
+
return if run_at || expire_at
|
114
|
+
|
115
|
+
# (30 + n_attempts^4) seconds is chosen to rapidly expand
|
116
|
+
# but with the baseline of 30s to avoid hitting the disk too frequently.
|
117
|
+
@run_at += 30 + (@attempts ** 4) unless @run_at.nil?
|
112
118
|
end
|
113
119
|
|
114
120
|
def to_h
|
115
121
|
{id: @id,
|
122
|
+
queue: @queue.name.to_s,
|
116
123
|
run_at: @run_at,
|
117
124
|
initial_run_at: @initial_run_at,
|
118
125
|
expire_at: @expire_at,
|
@@ -121,8 +128,45 @@ module Procrastinator
|
|
121
128
|
last_error: @last_error,
|
122
129
|
data: serialized_data}
|
123
130
|
end
|
124
|
-
end
|
125
131
|
|
126
|
-
|
132
|
+
def serialized_data
|
133
|
+
JSON.dump(@data)
|
134
|
+
end
|
135
|
+
|
136
|
+
def clear_fails
|
137
|
+
@last_error = nil
|
138
|
+
@last_fail_at = nil
|
139
|
+
end
|
140
|
+
|
141
|
+
private
|
142
|
+
|
143
|
+
def get_time(data)
|
144
|
+
case data
|
145
|
+
when NilClass
|
146
|
+
nil
|
147
|
+
when Numeric
|
148
|
+
Time.at data
|
149
|
+
when String
|
150
|
+
Time.parse data
|
151
|
+
when Time
|
152
|
+
data
|
153
|
+
else
|
154
|
+
return data.to_time if data.respond_to? :to_time
|
155
|
+
|
156
|
+
raise ArgumentError, "Unknown data type: #{ data.class } (#{ data })"
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def validate_run_at(run_at, expire_at)
|
161
|
+
return unless run_at
|
162
|
+
|
163
|
+
if expire_at && run_at > expire_at
|
164
|
+
raise ArgumentError, "new run_at (#{ run_at }) is later than new expire_at (#{ expire_at })"
|
165
|
+
end
|
166
|
+
|
167
|
+
return unless @expire_at && run_at > @expire_at
|
168
|
+
|
169
|
+
raise ArgumentError, "new run_at (#{ run_at }) is later than existing expire_at (#{ @expire_at })"
|
170
|
+
end
|
127
171
|
end
|
128
172
|
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pathname'
|
4
|
+
|
5
|
+
module Procrastinator
|
6
|
+
module TaskStore
|
7
|
+
# The general idea is that there may be two threads that need to do these actions on the same file:
|
8
|
+
# thread A: read
|
9
|
+
# thread B: read
|
10
|
+
# thread A/B: write
|
11
|
+
# thread A/B: write
|
12
|
+
#
|
13
|
+
# When this sequence happens, the second file write is based on old information and loses the info from
|
14
|
+
# the prior write. Using a global mutex per file path prevents this case.
|
15
|
+
#
|
16
|
+
# This situation can also occur with multi processing, so file locking is also used for solitary access.
|
17
|
+
# File locking is only advisory in some systems, though, so it may only work against other applications
|
18
|
+
# that request a lock.
|
19
|
+
#
|
20
|
+
# @author Robin Miller
|
21
|
+
class FileTransaction
|
22
|
+
# Holds the mutual exclusion locks for file paths by name
|
23
|
+
@file_mutex = {}
|
24
|
+
|
25
|
+
class << self
|
26
|
+
attr_reader :file_mutex
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize(path)
|
30
|
+
@path = ensure_path(path)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Alias for transact(writable: false)
|
34
|
+
def read(&block)
|
35
|
+
transact(writable: false, &block)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Alias for transact(writable: true)
|
39
|
+
def write(&block)
|
40
|
+
transact(writable: true, &block)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Completes the given block as an atomic transaction locked using a global mutex table.
|
44
|
+
# The block is provided the current file contents.
|
45
|
+
# The block's result is written to the file.
|
46
|
+
def transact(writable: false)
|
47
|
+
semaphore = FileTransaction.file_mutex[@path.to_s] ||= Mutex.new
|
48
|
+
|
49
|
+
semaphore.synchronize do
|
50
|
+
@path.open(writable ? 'r+' : 'r') do |file|
|
51
|
+
file.flock(File::LOCK_EX)
|
52
|
+
|
53
|
+
yield_result = yield(file.read)
|
54
|
+
if writable
|
55
|
+
file.rewind
|
56
|
+
file.write yield_result
|
57
|
+
file.truncate(file.pos)
|
58
|
+
end
|
59
|
+
yield_result
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def ensure_path(path)
|
67
|
+
path = Pathname.new path
|
68
|
+
unless path.exist?
|
69
|
+
path.dirname.mkpath
|
70
|
+
FileUtils.touch path
|
71
|
+
end
|
72
|
+
path
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'csv'
|
4
|
+
require 'pathname'
|
5
|
+
|
6
|
+
module Procrastinator
|
7
|
+
module TaskStore
|
8
|
+
# Simple Task I/O adapter that writes task information (ie. TaskMetaData attributes) to a CSV file.
|
9
|
+
#
|
10
|
+
# SimpleCommaStore is not designed for efficiency or large loads (10,000+ tasks).
|
11
|
+
#
|
12
|
+
# For critical production environments, it is strongly recommended to use a more robust storage mechanism like a
|
13
|
+
# proper database.
|
14
|
+
#
|
15
|
+
# @author Robin Miller
|
16
|
+
class SimpleCommaStore
|
17
|
+
# ordered
|
18
|
+
HEADERS = [:id, :queue, :run_at, :initial_run_at, :expire_at,
|
19
|
+
:attempts, :last_fail_at, :last_error, :data].freeze
|
20
|
+
|
21
|
+
EXT = 'csv'
|
22
|
+
DEFAULT_FILE = Pathname.new("procrastinator-tasks.#{ EXT }").freeze
|
23
|
+
|
24
|
+
TIME_FIELDS = [:run_at, :initial_run_at, :expire_at, :last_fail_at].freeze
|
25
|
+
|
26
|
+
READ_CONVERTER = proc do |value, field_info|
|
27
|
+
if field_info.header == :data
|
28
|
+
value
|
29
|
+
elsif TIME_FIELDS.include? field_info.header
|
30
|
+
value.empty? ? nil : Time.parse(value)
|
31
|
+
else
|
32
|
+
begin
|
33
|
+
Integer(value)
|
34
|
+
rescue ArgumentError
|
35
|
+
value
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
attr_reader :path
|
41
|
+
|
42
|
+
def initialize(file_path = DEFAULT_FILE)
|
43
|
+
@path = Pathname.new(file_path)
|
44
|
+
|
45
|
+
if @path.directory? || @path.to_s.end_with?('/')
|
46
|
+
@path /= DEFAULT_FILE
|
47
|
+
elsif @path.extname.empty?
|
48
|
+
@path = @path.dirname / "#{ @path.basename }.csv"
|
49
|
+
end
|
50
|
+
|
51
|
+
@path = @path.expand_path
|
52
|
+
|
53
|
+
freeze
|
54
|
+
end
|
55
|
+
|
56
|
+
def read(filter = {})
|
57
|
+
FileTransaction.new(@path).read do |existing_data|
|
58
|
+
parse(existing_data).select do |row|
|
59
|
+
filter.keys.all? do |key|
|
60
|
+
row[key] == filter[key]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Saves a task to the CSV file.
|
67
|
+
#
|
68
|
+
# @param queue [String] queue name
|
69
|
+
# @param run_at [Time, nil] time to run the task at
|
70
|
+
# @param initial_run_at [Time, nil] first time to run the task at. Defaults to run_at.
|
71
|
+
# @param expire_at [Time, nil] time to expire the task
|
72
|
+
def create(queue:, run_at:, expire_at:, data: '', initial_run_at: nil)
|
73
|
+
FileTransaction.new(@path).write do |existing_data|
|
74
|
+
tasks = parse(existing_data)
|
75
|
+
max_id = tasks.collect { |task| task[:id] }.max || 0
|
76
|
+
|
77
|
+
new_data = {
|
78
|
+
id: max_id + 1,
|
79
|
+
queue: queue,
|
80
|
+
run_at: run_at,
|
81
|
+
initial_run_at: initial_run_at || run_at,
|
82
|
+
expire_at: expire_at,
|
83
|
+
attempts: 0,
|
84
|
+
data: data
|
85
|
+
}
|
86
|
+
|
87
|
+
generate(tasks + [new_data])
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def update(id, data)
|
92
|
+
FileTransaction.new(@path).write do |existing_data|
|
93
|
+
tasks = parse(existing_data)
|
94
|
+
task_data = tasks.find do |task|
|
95
|
+
task[:id] == id
|
96
|
+
end
|
97
|
+
|
98
|
+
task_data&.merge!(data)
|
99
|
+
|
100
|
+
generate(tasks)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def delete(id)
|
105
|
+
FileTransaction.new(@path).write do |file_content|
|
106
|
+
existing_data = parse(file_content)
|
107
|
+
generate(existing_data.reject { |task| task[:id] == id })
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def generate(data)
|
112
|
+
lines = data.collect do |d|
|
113
|
+
TIME_FIELDS.each do |field|
|
114
|
+
d[field] = d[field]&.iso8601
|
115
|
+
end
|
116
|
+
CSV.generate_line(d, headers: HEADERS, force_quotes: true).strip
|
117
|
+
end
|
118
|
+
|
119
|
+
lines.unshift(HEADERS.join(','))
|
120
|
+
|
121
|
+
lines.join("\n") << "\n"
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
def parse(csv_string)
|
127
|
+
data = CSV.parse(csv_string,
|
128
|
+
headers: true,
|
129
|
+
header_converters: :symbol,
|
130
|
+
skip_blanks: true,
|
131
|
+
converters: READ_CONVERTER,
|
132
|
+
force_quotes: true).to_a
|
133
|
+
|
134
|
+
headers = data.shift || HEADERS
|
135
|
+
|
136
|
+
data = data.collect do |d|
|
137
|
+
headers.zip(d).to_h
|
138
|
+
end
|
139
|
+
|
140
|
+
correct_types(data)
|
141
|
+
end
|
142
|
+
|
143
|
+
def correct_types(data)
|
144
|
+
non_empty_keys = [:run_at, :expire_at, :attempts, :last_fail_at]
|
145
|
+
|
146
|
+
data.collect do |hash|
|
147
|
+
non_empty_keys.each do |key|
|
148
|
+
hash.delete(key) if hash[key].is_a?(String) && hash[key].empty?
|
149
|
+
end
|
150
|
+
|
151
|
+
hash[:attempts] ||= 0
|
152
|
+
|
153
|
+
# hash[:data] = (hash[:data] || '').gsub('""', '"')
|
154
|
+
hash[:queue] = hash[:queue].to_sym
|
155
|
+
|
156
|
+
hash
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Procrastinator
|
4
|
+
module Test
|
5
|
+
# Testing mock Task class
|
6
|
+
#
|
7
|
+
# You can use this like:
|
8
|
+
#
|
9
|
+
# require 'procrastinator/rspec/mocks'
|
10
|
+
# # ...
|
11
|
+
# Procrastinator.config do |c|
|
12
|
+
# c.define_queue :test_queue, Procrastinator::RSpec::MockTask
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
# @see MockDataTask for data-accepting tasks
|
16
|
+
class MockTask
|
17
|
+
attr_accessor :container, :logger, :scheduler
|
18
|
+
|
19
|
+
def run
|
20
|
+
@run = true
|
21
|
+
end
|
22
|
+
|
23
|
+
def run?
|
24
|
+
@run
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Data-accepting MockTask
|
29
|
+
#
|
30
|
+
# @see MockTask
|
31
|
+
class MockDataTask < MockTask
|
32
|
+
attr_accessor :data
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/procrastinator.rb
CHANGED
@@ -2,52 +2,37 @@
|
|
2
2
|
|
3
3
|
require 'procrastinator/version'
|
4
4
|
require 'procrastinator/task_meta_data'
|
5
|
-
require 'procrastinator/
|
5
|
+
require 'procrastinator/logged_task'
|
6
6
|
require 'procrastinator/queue'
|
7
7
|
require 'procrastinator/queue_worker'
|
8
8
|
require 'procrastinator/config'
|
9
|
-
require 'procrastinator/queue_manager'
|
10
9
|
require 'procrastinator/task'
|
11
10
|
require 'procrastinator/scheduler'
|
12
|
-
require 'procrastinator/
|
11
|
+
require 'procrastinator/task_store/file_transaction'
|
12
|
+
require 'procrastinator/task_store/simple_comma_store'
|
13
13
|
|
14
14
|
require 'logger'
|
15
15
|
require 'pathname'
|
16
16
|
|
17
17
|
# Top-level module for the Procrastinator Gem.
|
18
18
|
#
|
19
|
-
# Call Procrastinator.setup with a block to
|
20
|
-
# asynchronously from your main application.
|
19
|
+
# Call Procrastinator.setup with a block to configure task queues.
|
21
20
|
#
|
22
|
-
#
|
21
|
+
# See README for details.
|
23
22
|
#
|
24
23
|
# @author Robin Miller
|
25
24
|
#
|
26
25
|
# @see https://github.com/TenjinInc/procrastinator
|
27
26
|
module Procrastinator
|
28
|
-
# rubocop:disable Style/ClassVars
|
29
|
-
@@test_mode = false
|
30
|
-
|
31
|
-
def self.test_mode=(value)
|
32
|
-
@@test_mode = value
|
33
|
-
end
|
34
|
-
|
35
|
-
def self.test_mode
|
36
|
-
@@test_mode
|
37
|
-
end
|
38
|
-
|
39
|
-
# rubocop:enable Style/ClassVars
|
40
|
-
|
41
27
|
# Creates a configuration object and passes it into the given block.
|
42
28
|
#
|
43
29
|
# @yield the created configuration object
|
30
|
+
# @return [Scheduler] a scheduler object that can be used to interact with the queues
|
44
31
|
def self.setup(&block)
|
45
|
-
raise ArgumentError, 'Procrastinator.setup must be given a block' unless
|
46
|
-
|
47
|
-
config = Config.new
|
32
|
+
raise ArgumentError, 'Procrastinator.setup must be given a block' unless block
|
48
33
|
|
49
|
-
config.
|
34
|
+
config = Config.new(&block)
|
50
35
|
|
51
|
-
|
36
|
+
Scheduler.new(config)
|
52
37
|
end
|
53
38
|
end
|
data/procrastinator.gemspec
CHANGED
@@ -10,23 +10,27 @@ Gem::Specification.new do |spec|
|
|
10
10
|
spec.authors = ['Robin Miller']
|
11
11
|
spec.email = ['robin@tenjin.ca']
|
12
12
|
|
13
|
-
spec.summary = 'For apps
|
14
|
-
spec.description = 'A
|
13
|
+
spec.summary = 'For apps to put off work until later'
|
14
|
+
spec.description = 'A flexible pure Ruby job queue. Tasks are reschedulable after failures.'
|
15
15
|
spec.homepage = 'https://github.com/TenjinInc/procrastinator'
|
16
16
|
spec.license = 'MIT'
|
17
|
+
spec.metadata = {
|
18
|
+
'rubygems_mfa_required' => 'true'
|
19
|
+
}
|
17
20
|
|
18
21
|
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
19
22
|
spec.bindir = 'exe'
|
20
23
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
21
24
|
spec.require_paths = ['lib']
|
22
25
|
|
23
|
-
spec.required_ruby_version = '>= 2.
|
26
|
+
spec.required_ruby_version = '>= 2.4'
|
24
27
|
|
25
|
-
spec.add_development_dependency 'bundler', '~>
|
26
|
-
spec.add_development_dependency 'fakefs', '~>
|
27
|
-
spec.add_development_dependency 'rake', '~>
|
28
|
-
spec.add_development_dependency 'rspec', '~> 3.
|
29
|
-
spec.add_development_dependency 'rubocop', '~>
|
30
|
-
spec.add_development_dependency '
|
28
|
+
spec.add_development_dependency 'bundler', '~> 2.3'
|
29
|
+
spec.add_development_dependency 'fakefs', '~> 1.8'
|
30
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
31
|
+
spec.add_development_dependency 'rspec', '~> 3.9'
|
32
|
+
spec.add_development_dependency 'rubocop', '~> 1.12'
|
33
|
+
spec.add_development_dependency 'rubocop-performance', '~> 1.10'
|
34
|
+
spec.add_development_dependency 'simplecov', '~> 0.18.0'
|
31
35
|
spec.add_development_dependency 'timecop', '~> 0.9'
|
32
36
|
end
|