worker_tools 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 7b0794f3ef2c1157bf4254ec78087218ff50dcc4
4
+ data.tar.gz: 28cb0e939304f7d81becb39e6d223493b059c60e
5
+ SHA512:
6
+ metadata.gz: 78e98bd9a94c25f990f809b72d60257121467875154c2bda55a1b7ef65e25b7ecaba0faffebd932f22c775a213eb554886d87aab8080aab3458f3aaf8bc71ae8
7
+ data.tar.gz: 2d910f18319f729d0b05ced99d6fc24886308439e12c01ea2a58a09ee1320e4ee83bd3d2e0f36aa1915fff11fab7d578d5134805de0da7cd3c7b7fefe2ebcd51
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ .DS_Store
data/.rubocop.yml ADDED
@@ -0,0 +1,42 @@
1
+ # See all options in these files:
2
+ # https://github.com/bbatsov/rubocop/blob/master/config/default.yml
3
+ # https://github.com/bbatsov/rubocop/blob/master/config/enabled.yml
4
+ # https://github.com/bbatsov/rubocop/blob/master/config/disabled.yml
5
+
6
+ # run bundle exec rubocop -D to see what cop is reporting
7
+
8
+ # If you want to skip the check for line length in a specific case do:
9
+ # rubocop:disable LineLength
10
+ # superlong-line-with-unavoidable-excesive-length
11
+ # rubocop:enable LineLength
12
+
13
+ AllCops:
14
+ TargetRubyVersion: 2.3
15
+ Exclude:
16
+ - bin/*
17
+ - Rakefile
18
+
19
+ Documentation:
20
+ Enabled: false
21
+
22
+ Metrics/BlockLength:
23
+ Exclude:
24
+ - test/**/*
25
+ - ./*.gemspec
26
+
27
+ Metrics/LineLength:
28
+ Max: 120
29
+ Exclude:
30
+ - test/**/*
31
+
32
+ Metrics/MethodLength:
33
+ Exclude:
34
+ - test/**/*
35
+
36
+ Style/FrozenStringLiteralComment:
37
+ Enabled: false
38
+
39
+ # Although casecmp is more performant, downcase is more readable
40
+ # 'Foo'.downcase.eql?('foo') vs 'Foo'.casecmp('foo').zero?
41
+ Performance/Casecmp:
42
+ Enabled: false
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.1
5
+ before_install: gem install bundler -v 1.14.6
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in worker_tools.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 i22 Digitalagentur GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # WorkerTools
2
+
3
+ [![Build Status](https://travis-ci.org/i22-digitalagentur/worker-tools.svg?branch=master)](https://travis-ci.org/i22-digitalagentur/worker-tools)
4
+
5
+ WorkerTools is a collection of modules meant to speed up how we write background tasks following a few basic patterns. The structure of plain independent modules with limited abstraction allows to define and override a few methods according to your needs without requiring a deep investment in the library.
6
+
7
+ These modules provide some features and conventions to address the following points with little configuration on your part:
8
+
9
+ - How to save the state the task.
10
+ - How to save information relevant to the admins / customers.
11
+ - How to log the details
12
+ - How to handle exceptions and send notifications
13
+ - How to process CSV files (as input and output)
14
+ - How to process XLXS files (as input, output coming next)
15
+ - How to set options
16
+
17
+ ## Installation
18
+
19
+ Add this line to your application's Gemfile:
20
+
21
+ ```ruby
22
+ gem 'worker_tools'
23
+ ```
24
+
25
+ And then execute:
26
+
27
+ $ bundle
28
+
29
+ Or install it yourself as:
30
+
31
+ $ gem install worker_tools
32
+
33
+ ## Conventions
34
+
35
+ Most of the modules require an ActiveRecord model to keep track of the state, information, and files related to the job. The class of this model is typically an Import, Export, Report.. or something more generic like a JobEntry.
36
+
37
+ An example of this model for an Import using Paperclip would be something like this:
38
+
39
+ ```ruby
40
+ class Import < ApplicationRecord
41
+ enum state: { waiting: 0, complete: 1, failed: 2 }
42
+ enum kind: { foo: 0, bar: 1 }
43
+
44
+ has_attached_file :attachment
45
+
46
+ validates :kind, presence: true
47
+ validates :state, presence: true
48
+ end
49
+ ```
50
+
51
+ The state `complete` and `failed` are used by the modules. Both `state` and `kind` could be an enum or just a string field. Whether you have one, none or many attachments, and which library you use to handle it's up to you.
52
+
53
+ In this case the migration would be something like this:
54
+
55
+ ```ruby
56
+ def change
57
+ create_table :imports do |t|
58
+ t.integer :kind, null: false
59
+ t.integer :state, default: 0, null: false
60
+ t.text :information
61
+ t.json :options, default: {}
62
+
63
+ t.string :attachment_file_name
64
+ t.integer :attachment_file_size
65
+ t.string :attachment_content_type
66
+
67
+ t.timestamps
68
+ end
69
+ ```
70
+
71
+ ## Module 'Basics'
72
+
73
+ the [basics module](/lib/worker_tools/basics.rb) takes care of finding or creating the model, marking it as completed or failed, and calling any flow control wrappers around `run` that had been specified. (See wrappers)
74
+
75
+ A simple example would be as follows:
76
+
77
+ ```ruby
78
+ class MyImporter
79
+ include WorkerTools::Basic
80
+ wrappers :basics
81
+
82
+ def model_class
83
+ Import
84
+ end
85
+
86
+ def model_kind
87
+ 'foo'
88
+ end
89
+
90
+ def run
91
+ # do stuff
92
+ end
93
+ end
94
+ ```
95
+
96
+ The basics module contains a `perform` method, which is the usual entry point for ApplicationJob and Sidekiq. It can receive the id of the model, the model instance, or nothing, in which case it will attempt to create this model on its own.
97
+
98
+
99
+ ## Module 'Recorder'
100
+
101
+ Provides some methods to manage a log and the `information` field of the model. The main methods are `add_info`, `add_log`, and `record` (which both logs and appends the message to the information field). See all methods in [recorder](/lib/worker_tools/recorder.rb)
102
+
103
+ This module has a _recoder_ wrapper that will register the exception details into the log and information field in case of error:
104
+
105
+ ```ruby
106
+ class MyImporter
107
+ include WorkerTools::Basic
108
+ wrappers :basics, :recorder
109
+ # ...
110
+ end
111
+ ```
112
+
113
+ If you only want the logger functions, without worrying about persisting a model, you can use the `logger` wrapper and include the module as a stand alone component (without the basics module), like this:
114
+
115
+ ```ruby
116
+ class StandAloneWithLogging
117
+ include WorkerTools::Recorder
118
+
119
+ def perform
120
+ with_wrapper_logger do
121
+ # do stuff
122
+ end
123
+ end
124
+ end
125
+ ```
126
+
127
+ ## Module RocketchatErrorNotifier
128
+
129
+ [recorder](/lib/worker_tools/rocketchat_error_notifier.rb)
130
+
131
+ ## Module CSV Input
132
+
133
+ [csv_input](/lib/worker_tools/csv_input.rb)
134
+
135
+ ## Module CSV Output
136
+
137
+ [csv_output](/lib/worker_tools/csv_output.rb)
138
+
139
+ ## Module XLSX Input
140
+
141
+ [xlsx_input](/lib/worker_tools/xlsx_input.rb)
142
+
143
+
144
+ ## Wrappers
145
+
146
+ In the [basics module](/lib/worker_tools/basics.rb), `perform` calls your custom method `run` to do the actual work of the task, and wraps it around any methods expecting a block that you might have had defined using `wrappers`. That gives us a systematic way to add logic depending on the output of `run` and any exceptions that might arise, such as logging the error and context, sending a chat notification, retrying under some circumstances, etc.
147
+
148
+ The following code
149
+
150
+ ```ruby
151
+ class MyImporter
152
+ include WorkerTools::Basic
153
+ wrappers :basics
154
+
155
+ def run
156
+ # do stuff
157
+ end
158
+
159
+ # ..
160
+ end
161
+ ```
162
+
163
+ is internally handled as
164
+
165
+ ```ruby
166
+ def perform(model_id)
167
+ # set model
168
+ with_wrapper_basics do
169
+ run
170
+ end
171
+ end
172
+ ```
173
+
174
+ where this wrapper method looks like
175
+
176
+ ```ruby
177
+ def with_wrapper_basics(&block)
178
+ block.yield # calls run
179
+ # marks the import as complete
180
+ rescue Exception
181
+ # marks the import as failed
182
+ raise
183
+ end
184
+ ```
185
+
186
+ if we also add a wrapper to send notifications, such as
187
+ `wrappers :basics, :rocketchat_error_notifier`
188
+
189
+ the resulting nested calls would look like
190
+
191
+ ```ruby
192
+ def perform(model_id)
193
+ # set model
194
+ with_wrapper_basics do
195
+ with_wrapper_rocketchat_error_notifier do
196
+ run
197
+ end
198
+ end
199
+ end
200
+ ```
201
+
202
+
203
+ ## Contributing
204
+
205
+ Bug reports and pull requests are welcome on GitHub at https://github.com/i22-digitalagentur/worker_tools.
206
+
207
+
208
+ ## License
209
+
210
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
211
+
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList['test/**/*_test.rb']
8
+ end
9
+
10
+ task :default => :test
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "worker_tools"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,96 @@
1
+ require 'active_support/concern'
2
+
3
+ module WorkerTools
4
+ module Basics
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ attr_writer :model
9
+ attr_accessor :information
10
+
11
+ def self.wrappers(*args)
12
+ @wrappers ||= args.flatten
13
+ end
14
+
15
+ def self.read_wrappers
16
+ @wrappers || []
17
+ end
18
+ end
19
+
20
+ def model_class
21
+ # Ex: Import
22
+ raise "model_class has to be defined in #{self}"
23
+ end
24
+
25
+ def model_kind
26
+ # Ex: 'sdom'
27
+ raise "model_kind has to be defined in #{self}"
28
+ end
29
+
30
+ def run
31
+ raise "run has to be defined in #{self}"
32
+ end
33
+
34
+ def perform(model_id = nil)
35
+ @model_id = model_id
36
+ with_wrappers(wrapper_methods) do
37
+ run
38
+ end
39
+ end
40
+
41
+ def wrapper_methods
42
+ self.class.read_wrappers.map do |wrapper|
43
+ symbolized_method = "with_wrapper_#{wrapper}".to_sym
44
+ raise "Missing wrapper #{wrapper}" unless respond_to?(symbolized_method)
45
+ symbolized_method
46
+ end
47
+ end
48
+
49
+ def with_wrapper_basics(&block)
50
+ block.yield
51
+ finalize
52
+ # this time we do want to catch Exception to attempt to handle some of the
53
+ # critical errors.
54
+ # rubocop:disable Lint/RescueException
55
+ rescue Exception
56
+ # rubocop:enable Lint/RescueException
57
+ model.state = 'failed'
58
+ model.save!(validate: false)
59
+ raise
60
+ end
61
+
62
+ def finalize
63
+ model.update_attributes!(
64
+ state: 'complete',
65
+ information: information
66
+ )
67
+ end
68
+
69
+ def create_model_if_not_available
70
+ false
71
+ end
72
+
73
+ def model
74
+ @model ||= find_model
75
+ end
76
+
77
+ def with_wrappers(wrapper_symbols, &block)
78
+ return yield if wrapper_symbols.blank?
79
+ current_wrapper_symbol = wrapper_symbols.shift
80
+ send(current_wrapper_symbol) { with_wrappers(wrapper_symbols, &block) }
81
+ end
82
+
83
+ private
84
+
85
+ def find_model
86
+ @model_id ||= nil
87
+ return @model_id if @model_id.is_a?(model_class)
88
+ return model_class.find(@model_id) if @model_id
89
+ raise 'Model not available' unless create_model_if_not_available
90
+ t = model_class.new
91
+ t.kind = model_kind if t.respond_to?(:kind=)
92
+ t.save!(validate: false)
93
+ t
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,191 @@
1
+ require 'csv'
2
+
3
+ module WorkerTools
4
+ module CsvInput
5
+ # If an array is provided, the names will be used as the row keys, the row
6
+ # values will be assign according to the columns order.
7
+ #
8
+ # Ex: %w(tenant segment area)
9
+ # row => {
10
+ # tenant: _value_at_first_column_,
11
+ # segment: _value_at_second_column_,
12
+ # area: _value_at_third_column_
13
+ # }
14
+ #
15
+ # If a hash if provided, the keys will turn into the row keys, the values
16
+ # will be used to find the corresponding columns (the order in the csv won't
17
+ # affect the import)
18
+ #
19
+ # Ex: { tenant: 'Mandant', segment: 'Segment', area: 'Bereich')
20
+ # row => {
21
+ # tenant: _value_at_column_Mandant,
22
+ # segment: _value_at_column_Segment,
23
+ # area: _value_at_column_Bereich
24
+ # }
25
+ #
26
+ # The name of the column is filtered using the csv_input_header_normalized
27
+ # method, which takes care of extra spaces and looks for a case insentive
28
+ # match (so 'Bereich' matches ' Bereich', 'bereich', etc.). You can override
29
+ # that method as well.
30
+ #
31
+ # Besides matching the columns using strings, it is possible to use a regular
32
+ # expression or a proc:
33
+ # {
34
+ # tenant: 'Mandant',
35
+ # segment: /Segment/i,
36
+ # area: ->(name) { name.downcase == 'area' }
37
+ # }
38
+ def csv_input_columns
39
+ raise "csv_input_columns has to be defined in #{self}"
40
+ end
41
+
42
+ def csv_input_header_normalized(name)
43
+ name = name.to_s.strip
44
+ name = name.downcase if csv_input_header_normalize?
45
+ name
46
+ end
47
+
48
+ # Allows for some basic cleanup of the values, such as applying strip to
49
+ # the strings.
50
+ def cvs_input_value_cleanup(value)
51
+ value.is_a?(String) ? value.strip : value
52
+ end
53
+
54
+ def csv_input_columns_check(csv_rows_enum)
55
+ # override and return true if you do not want this check to be performed
56
+ return csv_input_columns_array_check(csv_rows_enum) if csv_input_columns.is_a?(Array)
57
+ csv_input_columns_hash_check(csv_rows_enum)
58
+ end
59
+
60
+ def csv_input_columns_array_check(csv_rows_enum)
61
+ expected_columns_length = csv_input_columns.length
62
+ actual_columns_length = csv_rows_enum.first.length
63
+ return if expected_columns_length == actual_columns_length
64
+ raise "The number of columns (#{actual_columns_length}) is not the expected (#{expected_columns_length})"
65
+ end
66
+
67
+ def csv_input_columns_hash_check(csv_rows_enum)
68
+ expected_names = csv_input_columns.values
69
+ filtered_actual_names = csv_rows_enum.first.map { |n| csv_input_header_normalized(n) }
70
+ csv_input_columns_hash_check_duplicates(filtered_actual_names)
71
+ csv_input_columns_hash_check_missing(filtered_actual_names, expected_names)
72
+ end
73
+
74
+ def csv_input_columns_hash_check_duplicates(names)
75
+ dups = names.group_by(&:itself).select { |_, v| v.count > 1 }.keys
76
+ raise "The file contains duplicated columns: #{dups}" if dups.present?
77
+ end
78
+
79
+ def csv_input_columns_hash_check_missing(actual_names, expected_names)
80
+ missing = expected_names.reject do |name|
81
+ matchable = name.is_a?(String) ? csv_input_header_normalized(name) : name
82
+ actual_names.any? { |n| case n when matchable then true end } # rubocop does not like ===
83
+ end
84
+ raise "Some columns are missing: #{missing}" unless missing.empty?
85
+ end
86
+
87
+ def csv_input_csv_options
88
+ # Ex: { col_sep: ';', encoding: Encoding::ISO_8859_1 }
89
+ { col_sep: ';' }
90
+ end
91
+
92
+ def csv_input_include_other_columns
93
+ false
94
+ end
95
+
96
+ def csv_input_header_normalize?
97
+ true
98
+ end
99
+
100
+ # Compares the first row (header names) with the csv_input_columns hash to find
101
+ # the corresponding positions.
102
+ #
103
+ # Ex: csv_input_columns: {tenant: 'Mandant', area: 'Bereich'}
104
+ # headers: ['Bereich', 'Mandant']
105
+ # => { tenant: 1, area: 0}
106
+ def csv_input_mapping_order(header_names)
107
+ return csv_input_columns.map.with_index { |n, i| [n, i] }.to_h if csv_input_columns.is_a?(Array)
108
+ csv_input_mapping_order_for_hash(header_names)
109
+ end
110
+
111
+ def csv_input_mapping_order_for_hash(header_names)
112
+ filtered_column_names = header_names.map { |n| csv_input_header_normalized(n) }
113
+ mapping = csv_input_columns.each_with_object({}) do |(k, v), h|
114
+ matchable = v.is_a?(String) ? csv_input_header_normalized(v) : v
115
+ h[k] = filtered_column_names.index { |n| case n when matchable then true end }
116
+ end
117
+ return mapping unless csv_input_include_other_columns
118
+ csv_input_mapping_order_with_other_columns(mapping, filtered_column_names)
119
+ end
120
+
121
+ def csv_input_mapping_order_with_other_columns(mapping, filtered_column_names)
122
+ positions_taken = mapping.values
123
+ filtered_column_names.each_with_index do |header, index|
124
+ mapping[header.to_sym] = index unless positions_taken.include?(index)
125
+ end
126
+ mapping
127
+ end
128
+
129
+ def csv_input_file_path
130
+ model.attachment.path.to_s
131
+ end
132
+
133
+ def csv_rows_enum
134
+ @csv_rows_enum ||= CSV.foreach(csv_input_file_path, csv_input_csv_options)
135
+ end
136
+
137
+ def csv_input_headers_present
138
+ true
139
+ end
140
+
141
+ def csv_input_foreach
142
+ @csv_input_foreach ||= begin
143
+ csv_input_columns_check(csv_rows_enum)
144
+
145
+ CsvInputForeach.new(
146
+ rows_enum: csv_rows_enum,
147
+ input_columns: csv_input_columns,
148
+ mapping_order: csv_input_mapping_order(csv_rows_enum.first),
149
+ cleanup_method: method(:cvs_input_value_cleanup),
150
+ headers_present: csv_input_headers_present
151
+ )
152
+ end
153
+ end
154
+
155
+ class CsvInputForeach
156
+ include Enumerable
157
+
158
+ def initialize(rows_enum:, input_columns:, mapping_order:, cleanup_method:, headers_present:)
159
+ @rows_enum = rows_enum
160
+ @input_columns = input_columns
161
+ @mapping_order = mapping_order
162
+ @cleanup_method = cleanup_method
163
+ @headers_present = headers_present
164
+ end
165
+
166
+ def each
167
+ return enum_for(:each) unless block_given?
168
+
169
+ @rows_enum.with_index.each do |values, index|
170
+ next if index.zero? && @headers_present
171
+ yield values_to_row(values)
172
+ end
173
+ end
174
+
175
+ def values_to_row(values)
176
+ return values_to_row_according_to_mapping(values) if @mapping_order
177
+ values_to_row_according_to_position(values)
178
+ end
179
+
180
+ def values_to_row_according_to_mapping(values)
181
+ @mapping_order.each_with_object(HashWithIndifferentAccess.new) do |(k, v), h|
182
+ h[k] = @cleanup_method.call(values[v])
183
+ end
184
+ end
185
+
186
+ def values_to_row_according_to_position(values)
187
+ @input_columns.map.with_index { |c, i| [c, @cleanup_method.call(values[i])] }.to_h.with_indifferent_access
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,82 @@
1
+ require 'csv'
2
+
3
+ module WorkerTools
4
+ module CsvOutput
5
+ # if defined, this file will be written to this destination (regardless
6
+ # of whether the model saves the file as well)
7
+ def csv_output_target
8
+ # Ex: Rails.root.join('shared', 'foo', 'bar.csv')
9
+ false
10
+ end
11
+
12
+ def csv_output_entries
13
+ raise "csv_output_entries has to be defined in #{self}"
14
+ end
15
+
16
+ def csv_output_column_headers
17
+ # These columns are used to set the headers, also
18
+ # to set the row values depending on your implementation.
19
+ #
20
+ # To ignore them set it to _false_
21
+ #
22
+ # Ex:
23
+ # @csv_output_column_headers ||= {
24
+ # foo: 'Foo Header',
25
+ # bar: 'Bar Header'
26
+ # }
27
+ raise "csv_output_column_headers has to be defined in #{self}"
28
+ end
29
+
30
+ # rubocop:disable Lint/UnusedMethodArgument
31
+ def csv_output_row_values(entry)
32
+ # Ex:
33
+ # {
34
+ # foo: entry.foo,
35
+ # bar: entry.bar
36
+ # }.values_at(*csv_output_column_headers.keys)
37
+ raise "csv_output_row_values has to be defined in #{self}"
38
+ end
39
+ # rubocop:enable Lint/UnusedMethodArgument
40
+
41
+ def cvs_output_target_folder
42
+ File.dirname(csv_output_target)
43
+ end
44
+
45
+ def csv_output_target_file_name
46
+ File.basename(csv_output_target)
47
+ end
48
+
49
+ def csv_ouput_ensure_target_folder
50
+ FileUtils.mkdir_p(cvs_output_target_folder) unless File.directory?(cvs_output_target_folder)
51
+ end
52
+
53
+ def csv_output_tmp_file
54
+ @csv_output_tmp_file ||= Tempfile.new(['output', '.csv'])
55
+ end
56
+
57
+ def csv_output_col_sep
58
+ ';'
59
+ end
60
+
61
+ def csv_output_encoding
62
+ Encoding::UTF_8
63
+ end
64
+
65
+ def csv_output_insert_headers(csv)
66
+ csv << csv_output_column_headers.values if csv_output_column_headers
67
+ end
68
+
69
+ def csv_output_write_file
70
+ CSV.open(csv_output_tmp_file, 'wb', col_sep: csv_output_col_sep, encoding: csv_output_encoding) do |csv|
71
+ csv_output_insert_headers(csv)
72
+ csv_output_entries.each { |entry| csv << csv_output_row_values(entry) }
73
+ end
74
+ csv_output_write_target if csv_output_target
75
+ end
76
+
77
+ def csv_output_write_target
78
+ csv_ouput_ensure_target_folder
79
+ FileUtils.cp(csv_output_tmp_file.path, csv_output_target)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,81 @@
1
+ module WorkerTools
2
+ module Recorder
3
+ def with_wrapper_recorder(&block)
4
+ block.yield
5
+ # this time we do want to catch Exception to attempt to handle some of the
6
+ # critical errors.
7
+ # rubocop:disable Lint/RescueException
8
+ rescue Exception => e
9
+ # rubocop:enable Lint/RescueException
10
+ record_fail(e)
11
+ raise
12
+ end
13
+
14
+ def with_wrapper_logger(&block)
15
+ block.yield
16
+ # this time we do want to catch Exception to attempt to handle some of the
17
+ # critical errors.
18
+ # rubocop:disable Lint/RescueException
19
+ rescue Exception => e
20
+ # rubocop:enable Lint/RescueException
21
+ add_log(e, :error)
22
+ raise
23
+ end
24
+
25
+ def record_fail(error)
26
+ record "ID #{model.id} - Error"
27
+ record(error, :error)
28
+ model.information = information
29
+ model.save!(validate: false)
30
+ end
31
+
32
+ def add_log(message, level = :info)
33
+ logger.public_send(level, format_log_message(message))
34
+ end
35
+
36
+ def add_info(message)
37
+ @information ||= ''
38
+ information << "#{format_info_message(message)}\n"
39
+ end
40
+
41
+ def record(message, level = :info)
42
+ add_log(message, level)
43
+ add_info(message)
44
+ end
45
+
46
+ def format_log_message(message)
47
+ return error_to_text(message, log_error_trace_lines) if message.is_a?(Exception)
48
+ message
49
+ end
50
+
51
+ def format_info_message(message)
52
+ return error_to_text(message, info_error_trace_lines) if message.is_a?(Exception)
53
+ message
54
+ end
55
+
56
+ def logger
57
+ @logger ||= Logger.new(File.join(log_directory, log_file_name))
58
+ end
59
+
60
+ def log_directory
61
+ Rails.root.join('log')
62
+ end
63
+
64
+ def log_file_name
65
+ "#{self.class.name.underscore.tr('/', '_')}.log"
66
+ end
67
+
68
+ def log_error_trace_lines
69
+ 20
70
+ end
71
+
72
+ def info_error_trace_lines
73
+ 20
74
+ end
75
+
76
+ def error_to_text(error, trace_lines = 20)
77
+ txt = "Error: #{error.message} (#{error.class})"
78
+ txt << "Backtrace:\n#{error.backtrace[0, trace_lines].join("\n\t")}"
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,68 @@
1
+ module WorkerTools
2
+ module RocketchatErrorNotifier
3
+ def with_wrapper_rocketchat_error_notifier(&block)
4
+ block.yield
5
+ rescue StandardError => e
6
+ rocketchat_error_notify(e) if rocketchat_error_notifier_enabled
7
+ raise
8
+ end
9
+
10
+ def rocketchat_error_notifier_enabled
11
+ Rails.env.production?
12
+ end
13
+
14
+ def rocketchat_error_notifier_emoji
15
+ ':red_circle:'
16
+ end
17
+
18
+ def rocketchat_error_notifier_receivers
19
+ # Ex: '@all'
20
+ end
21
+
22
+ def rocketchat_error_notifier_event
23
+ 'Worker Error Notifier'
24
+ end
25
+
26
+ def rocketchat_error_notifier_title
27
+ # Example with a link:
28
+ #
29
+ # For urls a default_url_options[:host] might be necessary.
30
+ # In this example I just copy it from existing action_mailer defaults.
31
+ #
32
+ # import = rocketchat_error_notifier_model
33
+ # host = Rails.application.config.action_mailer.default_url_options[:host]
34
+ # url = Rails.application.routes.url_helpers.import_url(import, host: host, protocol: :https)
35
+ # kind = I18n.t(import.kind, scope: 'import.kinds')
36
+ # text = "##{import.id} *#{kind}*"
37
+ # "[#{text}](#{url})"
38
+ klass = model.class.model_name.human
39
+ kind = I18n.t("activerecord.attributes.#{model.class.name.underscore}.kinds.#{model.kind}")
40
+ "#{klass} #{kind} ##{model.id}"
41
+ end
42
+
43
+ def rocketchat_error_notifier_error_details(error)
44
+ details = "#{error.class}: #{error.message}\n"
45
+ details << error.backtrace[0..10].join("\n")
46
+ end
47
+
48
+ def rocketchat_error_notifier_message
49
+ message = []
50
+ message << rocketchat_error_notifier_receivers
51
+ message << rocketchat_error_notifier_title
52
+ message.compact.join(' - ')
53
+ end
54
+
55
+ def rocketchat_error_notifier_attachment(error)
56
+ { collapsed: true, title: 'Error', text: rocketchat_error_notifier_error_details(error) }
57
+ end
58
+
59
+ def rocketchat_error_notify(error)
60
+ RocketChatNotifier.notify(
61
+ rocketchat_error_notifier_message,
62
+ emoji: rocketchat_error_notifier_emoji,
63
+ event: "#{rocketchat_error_notifier_event} (#{Rails.env})",
64
+ attachment: rocketchat_error_notifier_attachment(error)
65
+ )
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ module WorkerTools
2
+ VERSION = '0.1.1'.freeze
3
+ end
@@ -0,0 +1,169 @@
1
+ require 'roo'
2
+
3
+ module WorkerTools
4
+ module XlsxInput
5
+ # If an array is provided, the names will be used as the row keys, the row
6
+ # values will be assign according to the columns order.
7
+ #
8
+ # Ex: %w(tenant segment area)
9
+ # row => {
10
+ # tenant: _value_at_first_column_,
11
+ # segment: _value_at_second_column_,
12
+ # area: _value_at_third_column_
13
+ # }
14
+ #
15
+ # If a hash if provided, the keys will turn into the row keys, the values
16
+ # will be used to find the corresponding columns (the order in the spreadsheet
17
+ # won't affect the import)
18
+ #
19
+ # Ex: { tenant: 'Mandant', segment: 'Segment', area: 'Bereich')
20
+ # row => {
21
+ # tenant: _value_at_column_Mandant,
22
+ # segment: _value_at_column_Segment,
23
+ # area: _value_at_column_Bereich
24
+ # }
25
+ #
26
+ # The name of the column is filtered using the xlsx_input_header_normalized
27
+ # method, which takes care of extra spaces and looks for a case insentive
28
+ # match (so 'Bereich' matches ' Bereich', 'bereich', etc.). You can override
29
+ # that method as well.
30
+ #
31
+ # Besides matching the columns using strings, it is possible to use a regular
32
+ # expression or a proc:
33
+ # {
34
+ # tenant: 'Mandant',
35
+ # segment: /Segment/i,
36
+ # area: ->(name) { name.downcase == 'area' }
37
+ # }
38
+ def xlsx_input_columns
39
+ raise "xlsx_input_columns has to be defined in #{self}"
40
+ end
41
+
42
+ # If true, the rows will append those columns that don't belong to the
43
+ # xlsx_input_columns list. Useful when the spreadsheet contains some fixed
44
+ # columns and a number of variable ones.
45
+ def xlsx_input_include_other_columns
46
+ false
47
+ end
48
+
49
+ def xlsx_input_header_normalized(name)
50
+ name.to_s.strip.downcase
51
+ end
52
+
53
+ # Allows for some basic cleanup of the values, such as applying strip to
54
+ # the strings.
55
+ def xlsx_input_value_cleanup(value)
56
+ value.is_a?(String) ? value.strip : value
57
+ end
58
+
59
+ def xlsx_input_columns_check(xlsx_rows_enum)
60
+ # override and return true if you do not want this check to be performed
61
+ return xlsx_input_columns_array_check(xlsx_rows_enum) if xlsx_input_columns.is_a?(Array)
62
+ xlsx_input_columns_hash_check(xlsx_rows_enum)
63
+ end
64
+
65
+ def xlsx_input_columns_array_check(xlsx_rows_enum)
66
+ expected_columns_length = xlsx_input_columns.length
67
+ actual_columns_length = xlsx_rows_enum.first.length
68
+ return if expected_columns_length == actual_columns_length
69
+ raise "The number of columns (#{actual_columns_length}) is not the expected (#{expected_columns_length})"
70
+ end
71
+
72
+ def xlsx_input_columns_hash_check(xlsx_rows_enum)
73
+ expected_names = xlsx_input_columns.values
74
+ filtered_actual_names = xlsx_rows_enum.first.map { |n| xlsx_input_header_normalized(n) }
75
+ xlsx_input_columns_hash_check_duplicates(filtered_actual_names)
76
+ xlsx_input_columns_hash_check_missing(filtered_actual_names, expected_names)
77
+ end
78
+
79
+ def xlsx_input_columns_hash_check_duplicates(names)
80
+ dups = names.group_by(&:itself).select { |_, v| v.count > 1 }.keys
81
+ raise "The file contains duplicated columns: #{dups}" if dups.present?
82
+ end
83
+
84
+ def xlsx_input_columns_hash_check_missing(actual_names, expected_names)
85
+ missing = expected_names.reject do |name|
86
+ matchable = name.is_a?(String) ? xlsx_input_header_normalized(name) : name
87
+ actual_names.any? { |n| case n when matchable then true end } # rubocop does not like ===
88
+ end
89
+ raise "Some columns are missing: #{missing}" unless missing.empty?
90
+ end
91
+
92
+ # Compares the first row (header names) with the xlsx_input_columns hash to find
93
+ # the corresponding positions.
94
+ #
95
+ # Ex: xlsx_input_columns: {tenant: 'Mandant', area: 'Bereich'}
96
+ # headers: ['Bereich', 'Mandant']
97
+ # => { tenant: 1, area: 0}
98
+ def xlsx_input_mapping_order(header_names)
99
+ return xlsx_input_columns.map.with_index { |n, i| [n, i] }.to_h if xlsx_input_columns.is_a?(Array)
100
+ xlsx_input_mapping_order_for_hash(header_names)
101
+ end
102
+
103
+ def xlsx_input_mapping_order_for_hash(header_names)
104
+ filtered_column_names = header_names.map { |n| xlsx_input_header_normalized(n) }
105
+ mapping = xlsx_input_columns.each_with_object({}) do |(k, v), h|
106
+ matchable = v.is_a?(String) ? xlsx_input_header_normalized(v) : v
107
+ h[k] = filtered_column_names.index { |n| case n when matchable then true end }
108
+ end
109
+ return mapping unless xlsx_input_include_other_columns
110
+ xlsx_input_mapping_order_with_other_columns(mapping, filtered_column_names)
111
+ end
112
+
113
+ def xlsx_input_mapping_order_with_other_columns(mapping, filtered_column_names)
114
+ positions_taken = mapping.values
115
+ filtered_column_names.each_with_index do |header, index|
116
+ mapping[header] = index unless positions_taken.include?(index)
117
+ end
118
+ mapping
119
+ end
120
+
121
+ def xlsx_input_file_path
122
+ model.attachment.path.to_s
123
+ end
124
+
125
+ def xlsx_rows_enum
126
+ @xlsx_rows_enum ||= begin
127
+ spreadsheet = Roo::Excelx.new(xlsx_input_file_path)
128
+ spreadsheet.each_row_streaming(sheet: spreadsheet.sheets.first, pad_cells: true)
129
+ end
130
+ end
131
+
132
+ def xlsx_input_foreach
133
+ @xlsx_input_foreach ||= begin
134
+ xlsx_input_columns_check(xlsx_rows_enum)
135
+
136
+ XlsxInputForeach.new(
137
+ rows_enum: xlsx_rows_enum,
138
+ mapping_order: xlsx_input_mapping_order(xlsx_rows_enum.first),
139
+ cleanup_method: method(:xlsx_input_value_cleanup)
140
+ )
141
+ end
142
+ end
143
+
144
+ class XlsxInputForeach
145
+ include Enumerable
146
+
147
+ def initialize(rows_enum:, mapping_order:, cleanup_method:)
148
+ @rows_enum = rows_enum
149
+ @mapping_order = mapping_order
150
+ @cleanup_method = cleanup_method
151
+ end
152
+
153
+ def each
154
+ return enum_for(:each) unless block_given?
155
+
156
+ @rows_enum.with_index.each do |values, index|
157
+ next if index.zero? # headers
158
+ yield values_to_row(values)
159
+ end
160
+ end
161
+
162
+ def values_to_row(values)
163
+ @mapping_order.each_with_object(HashWithIndifferentAccess.new) do |(k, v), h|
164
+ h[k] = @cleanup_method.call(values[v].try(:value))
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,4 @@
1
+ Dir[File.join(__dir__, 'worker_tools/*.rb')].each { |path| require path }
2
+
3
+ module WorkerTools
4
+ end
@@ -0,0 +1,43 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'worker_tools/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'worker_tools'
7
+ spec.version = WorkerTools::VERSION
8
+ spec.authors = ['fsainz']
9
+ spec.email = ['fernando.sainz@i22.de']
10
+
11
+ spec.summary = 'A collection of modules to help writing common worker tasks)'
12
+ # spec.description = %q{TODO: Write a longer description or delete this line.}
13
+ spec.homepage = 'https://github.com/i22-digitalagentur/worker-tools'
14
+ spec.license = 'MIT'
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host"
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.' unless spec.respond_to?(:metadata)
19
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
20
+
21
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
22
+ f.match(%r{^(test|spec|features)/})
23
+ end
24
+ spec.bindir = 'exe'
25
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
+ spec.require_paths = ['lib']
27
+
28
+ spec.add_dependency 'activesupport'
29
+ spec.add_dependency 'rocketchat-notifier', '>= 0.1.2'
30
+ spec.add_dependency 'roo'
31
+
32
+ spec.add_development_dependency 'activerecord'
33
+ spec.add_development_dependency 'bundler'
34
+ spec.add_development_dependency 'database_cleaner'
35
+ spec.add_development_dependency 'm'
36
+ spec.add_development_dependency 'minitest'
37
+ spec.add_development_dependency 'mocha'
38
+ spec.add_development_dependency 'pry'
39
+ spec.add_development_dependency 'rake'
40
+ spec.add_development_dependency 'rubocop', '0.52.1'
41
+ spec.add_development_dependency 'simplecov'
42
+ spec.add_development_dependency 'sqlite3'
43
+ end
metadata ADDED
@@ -0,0 +1,259 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: worker_tools
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - fsainz
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-06-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rocketchat-notifier
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 0.1.2
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 0.1.2
41
+ - !ruby/object:Gem::Dependency
42
+ name: roo
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: activerecord
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: bundler
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: database_cleaner
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: m
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: minitest
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: mocha
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: pry
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rake
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ - !ruby/object:Gem::Dependency
168
+ name: rubocop
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - '='
172
+ - !ruby/object:Gem::Version
173
+ version: 0.52.1
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - '='
179
+ - !ruby/object:Gem::Version
180
+ version: 0.52.1
181
+ - !ruby/object:Gem::Dependency
182
+ name: simplecov
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ - !ruby/object:Gem::Dependency
196
+ name: sqlite3
197
+ requirement: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - ">="
200
+ - !ruby/object:Gem::Version
201
+ version: '0'
202
+ type: :development
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ version: '0'
209
+ description:
210
+ email:
211
+ - fernando.sainz@i22.de
212
+ executables: []
213
+ extensions: []
214
+ extra_rdoc_files: []
215
+ files:
216
+ - ".gitignore"
217
+ - ".rubocop.yml"
218
+ - ".travis.yml"
219
+ - Gemfile
220
+ - LICENSE
221
+ - README.md
222
+ - Rakefile
223
+ - bin/console
224
+ - bin/setup
225
+ - lib/worker_tools.rb
226
+ - lib/worker_tools/basics.rb
227
+ - lib/worker_tools/csv_input.rb
228
+ - lib/worker_tools/csv_output.rb
229
+ - lib/worker_tools/recorder.rb
230
+ - lib/worker_tools/rocketchat_error_notifier.rb
231
+ - lib/worker_tools/version.rb
232
+ - lib/worker_tools/xlsx_input.rb
233
+ - worker_tools.gemspec
234
+ homepage: https://github.com/i22-digitalagentur/worker-tools
235
+ licenses:
236
+ - MIT
237
+ metadata:
238
+ allowed_push_host: https://rubygems.org
239
+ post_install_message:
240
+ rdoc_options: []
241
+ require_paths:
242
+ - lib
243
+ required_ruby_version: !ruby/object:Gem::Requirement
244
+ requirements:
245
+ - - ">="
246
+ - !ruby/object:Gem::Version
247
+ version: '0'
248
+ required_rubygems_version: !ruby/object:Gem::Requirement
249
+ requirements:
250
+ - - ">="
251
+ - !ruby/object:Gem::Version
252
+ version: '0'
253
+ requirements: []
254
+ rubyforge_project:
255
+ rubygems_version: 2.5.1
256
+ signing_key:
257
+ specification_version: 4
258
+ summary: A collection of modules to help writing common worker tasks)
259
+ test_files: []