stockboy 0.5.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 +7 -0
- data/.gitignore +9 -0
- data/.rspec +5 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +24 -0
- data/Gemfile +12 -0
- data/Guardfile +10 -0
- data/LICENSE +21 -0
- data/README.md +293 -0
- data/Rakefile +30 -0
- data/lib/stockboy.rb +80 -0
- data/lib/stockboy/attribute.rb +11 -0
- data/lib/stockboy/attribute_map.rb +74 -0
- data/lib/stockboy/candidate_record.rb +130 -0
- data/lib/stockboy/configuration.rb +62 -0
- data/lib/stockboy/configurator.rb +176 -0
- data/lib/stockboy/dsl.rb +68 -0
- data/lib/stockboy/exceptions.rb +3 -0
- data/lib/stockboy/filter.rb +58 -0
- data/lib/stockboy/filter_chain.rb +41 -0
- data/lib/stockboy/filters.rb +11 -0
- data/lib/stockboy/filters/missing_email.rb +37 -0
- data/lib/stockboy/job.rb +241 -0
- data/lib/stockboy/mapped_record.rb +59 -0
- data/lib/stockboy/provider.rb +238 -0
- data/lib/stockboy/providers.rb +11 -0
- data/lib/stockboy/providers/file.rb +135 -0
- data/lib/stockboy/providers/ftp.rb +205 -0
- data/lib/stockboy/providers/http.rb +123 -0
- data/lib/stockboy/providers/imap.rb +290 -0
- data/lib/stockboy/providers/soap.rb +120 -0
- data/lib/stockboy/railtie.rb +28 -0
- data/lib/stockboy/reader.rb +59 -0
- data/lib/stockboy/readers.rb +11 -0
- data/lib/stockboy/readers/csv.rb +115 -0
- data/lib/stockboy/readers/fixed_width.rb +121 -0
- data/lib/stockboy/readers/spreadsheet.rb +144 -0
- data/lib/stockboy/readers/xml.rb +155 -0
- data/lib/stockboy/registry.rb +42 -0
- data/lib/stockboy/source_record.rb +43 -0
- data/lib/stockboy/string_pool.rb +35 -0
- data/lib/stockboy/template_file.rb +44 -0
- data/lib/stockboy/translations.rb +70 -0
- data/lib/stockboy/translations/boolean.rb +58 -0
- data/lib/stockboy/translations/date.rb +41 -0
- data/lib/stockboy/translations/decimal.rb +33 -0
- data/lib/stockboy/translations/default_empty_string.rb +38 -0
- data/lib/stockboy/translations/default_false.rb +41 -0
- data/lib/stockboy/translations/default_nil.rb +38 -0
- data/lib/stockboy/translations/default_true.rb +41 -0
- data/lib/stockboy/translations/default_zero.rb +41 -0
- data/lib/stockboy/translations/integer.rb +33 -0
- data/lib/stockboy/translations/string.rb +33 -0
- data/lib/stockboy/translations/time.rb +41 -0
- data/lib/stockboy/translations/uk_date.rb +51 -0
- data/lib/stockboy/translations/us_date.rb +51 -0
- data/lib/stockboy/translator.rb +66 -0
- data/lib/stockboy/version.rb +3 -0
- data/spec/fixtures/.gitkeep +0 -0
- data/spec/fixtures/files/a_garbage.csv +1 -0
- data/spec/fixtures/files/test_data-20120101.csv +1 -0
- data/spec/fixtures/files/test_data-20120202.csv +1 -0
- data/spec/fixtures/files/z_garbage.csv +1 -0
- data/spec/fixtures/jobs/test_job.rb +1 -0
- data/spec/fixtures/soap/get_list/fault.xml +8 -0
- data/spec/fixtures/soap/get_list/success.xml +18 -0
- data/spec/fixtures/spreadsheets/test_data.xls +0 -0
- data/spec/fixtures/spreadsheets/test_row_options.xls +0 -0
- data/spec/fixtures/xml/body.xml +14 -0
- data/spec/spec_helper.rb +28 -0
- data/spec/stockboy/attribute_map_spec.rb +59 -0
- data/spec/stockboy/attribute_spec.rb +11 -0
- data/spec/stockboy/candidate_record_spec.rb +150 -0
- data/spec/stockboy/configuration_spec.rb +28 -0
- data/spec/stockboy/configurator_spec.rb +127 -0
- data/spec/stockboy/filter_chain_spec.rb +40 -0
- data/spec/stockboy/filter_spec.rb +41 -0
- data/spec/stockboy/filters/missing_email_spec.rb +26 -0
- data/spec/stockboy/filters_spec.rb +38 -0
- data/spec/stockboy/job_spec.rb +238 -0
- data/spec/stockboy/mapped_record_spec.rb +30 -0
- data/spec/stockboy/provider_spec.rb +34 -0
- data/spec/stockboy/providers/file_spec.rb +116 -0
- data/spec/stockboy/providers/ftp_spec.rb +143 -0
- data/spec/stockboy/providers/http_spec.rb +94 -0
- data/spec/stockboy/providers/imap_spec.rb +76 -0
- data/spec/stockboy/providers/soap_spec.rb +107 -0
- data/spec/stockboy/providers_spec.rb +38 -0
- data/spec/stockboy/readers/csv_spec.rb +68 -0
- data/spec/stockboy/readers/fixed_width_spec.rb +52 -0
- data/spec/stockboy/readers/spreadsheet_spec.rb +121 -0
- data/spec/stockboy/readers/xml_spec.rb +94 -0
- data/spec/stockboy/readers_spec.rb +30 -0
- data/spec/stockboy/source_record_spec.rb +19 -0
- data/spec/stockboy/template_file_spec.rb +30 -0
- data/spec/stockboy/translations/boolean_spec.rb +48 -0
- data/spec/stockboy/translations/date_spec.rb +38 -0
- data/spec/stockboy/translations/decimal_spec.rb +23 -0
- data/spec/stockboy/translations/default_empty_string_spec.rb +32 -0
- data/spec/stockboy/translations/default_false_spec.rb +25 -0
- data/spec/stockboy/translations/default_nil_spec.rb +32 -0
- data/spec/stockboy/translations/default_true_spec.rb +25 -0
- data/spec/stockboy/translations/default_zero_spec.rb +32 -0
- data/spec/stockboy/translations/integer_spec.rb +22 -0
- data/spec/stockboy/translations/string_spec.rb +22 -0
- data/spec/stockboy/translations/time_spec.rb +27 -0
- data/spec/stockboy/translations/uk_date_spec.rb +37 -0
- data/spec/stockboy/translations/us_date_spec.rb +37 -0
- data/spec/stockboy/translations_spec.rb +55 -0
- data/spec/stockboy/translator_spec.rb +27 -0
- data/stockboy.gemspec +32 -0
- metadata +305 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Stockboy
|
|
2
|
+
|
|
3
|
+
# This represents the "output" side of a {CandidateRecord}
|
|
4
|
+
#
|
|
5
|
+
# Based on the current attribute map, it will have reader methods for the
|
|
6
|
+
# output values of each attribute. This is similar to an OpenStruct, but
|
|
7
|
+
# more efficient since we cache the defined methods.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# output = MappedRecord.new(first_name: "Zaphod")
|
|
11
|
+
# output.first_name # => "Zaphod"
|
|
12
|
+
#
|
|
13
|
+
class MappedRecord
|
|
14
|
+
|
|
15
|
+
# This is an optimization to avoid relying on method_missing.
|
|
16
|
+
#
|
|
17
|
+
# This module holds a pool of already-defined accessor methods for sets of
|
|
18
|
+
# record attributes. Each set of methods is held in a module that gets
|
|
19
|
+
# extended into new MappedRecords.
|
|
20
|
+
#
|
|
21
|
+
# @visibility private
|
|
22
|
+
#
|
|
23
|
+
module AccessorMethods
|
|
24
|
+
def self.for(attrs)
|
|
25
|
+
@module_registry ||= Hash.new
|
|
26
|
+
@module_registry[attrs] ||= build_module(attrs)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.build_module(attr_accessor_keys)
|
|
30
|
+
Module.new do
|
|
31
|
+
attr_accessor_keys.each do |key|
|
|
32
|
+
define_method key do
|
|
33
|
+
@fields[key]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Initialize a new MappedRecord
|
|
41
|
+
#
|
|
42
|
+
# @param [Hash<Symbol>] fields
|
|
43
|
+
# Keys map to reader methods
|
|
44
|
+
#
|
|
45
|
+
def initialize(fields)
|
|
46
|
+
mod = AccessorMethods.for(fields.keys)
|
|
47
|
+
extend mod
|
|
48
|
+
@fields = fields
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [String]
|
|
53
|
+
#
|
|
54
|
+
def to_s
|
|
55
|
+
@fields.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
require 'logger'
|
|
2
|
+
require 'active_model/errors'
|
|
3
|
+
require 'active_model/naming'
|
|
4
|
+
require 'stockboy/dsl'
|
|
5
|
+
require 'stockboy/exceptions'
|
|
6
|
+
|
|
7
|
+
module Stockboy
|
|
8
|
+
|
|
9
|
+
# Provider objects handle the connection and capture of data from remote
|
|
10
|
+
# sources. This is an abstract superclass to help implement different
|
|
11
|
+
# providers.
|
|
12
|
+
#
|
|
13
|
+
# == Interface
|
|
14
|
+
#
|
|
15
|
+
# A provider object must implement the following (private) methods:
|
|
16
|
+
#
|
|
17
|
+
# [validate]
|
|
18
|
+
# Verify the parameters required for the data source are set to
|
|
19
|
+
# ensure a connection can be established.
|
|
20
|
+
# [fetch_data]
|
|
21
|
+
# Populate the object's +@data+ with raw content from source. This will
|
|
22
|
+
# usually be a raw string, and should not be parsed at this stage.
|
|
23
|
+
# Depending on the implementation, this may involve any of:
|
|
24
|
+
# * Establishing a connection
|
|
25
|
+
# * Navigating to a directory
|
|
26
|
+
# * Listing available files matching the configuration
|
|
27
|
+
# * Picking the appropriate file
|
|
28
|
+
# * And finally, reading/downloading data
|
|
29
|
+
# This should also capture the timestamp of the data resource into
|
|
30
|
+
# +@data_time+. This should be the actual created or updated time of the
|
|
31
|
+
# data file from the source.
|
|
32
|
+
#
|
|
33
|
+
# @abstract
|
|
34
|
+
#
|
|
35
|
+
class Provider
|
|
36
|
+
extend Stockboy::DSL
|
|
37
|
+
extend ActiveModel::Naming # Required by ActiveModel::Errors
|
|
38
|
+
|
|
39
|
+
# Default logger if none is provided to the instance
|
|
40
|
+
#
|
|
41
|
+
# @return [Logger]
|
|
42
|
+
#
|
|
43
|
+
def self.logger
|
|
44
|
+
Logger.new(STDERR)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Logger]
|
|
48
|
+
#
|
|
49
|
+
attr_accessor :logger
|
|
50
|
+
|
|
51
|
+
# @return [ActiveModel::Errors]
|
|
52
|
+
#
|
|
53
|
+
attr_reader :errors
|
|
54
|
+
|
|
55
|
+
# Timestamp of the received data
|
|
56
|
+
#
|
|
57
|
+
# @return [Time]
|
|
58
|
+
#
|
|
59
|
+
attr_reader :data_time
|
|
60
|
+
|
|
61
|
+
# @return [String]
|
|
62
|
+
#
|
|
63
|
+
def inspect
|
|
64
|
+
"#<#{self.class}:#{self.object_id} data_size=#{@data_size or 'nil'} errors=#{@errors.full_messages}>"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Must be called by subclasses via +super+ to set up dependencies
|
|
68
|
+
#
|
|
69
|
+
# @param [Hash] opts
|
|
70
|
+
# @yield DSL context for configuration
|
|
71
|
+
#
|
|
72
|
+
def initialize(opts={}, &block)
|
|
73
|
+
@logger = opts.delete(:logger) || Stockboy.configuration.logger
|
|
74
|
+
clear
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Raw input data from the source
|
|
78
|
+
#
|
|
79
|
+
# @!attribute [r] data
|
|
80
|
+
#
|
|
81
|
+
def data
|
|
82
|
+
return @data if @data
|
|
83
|
+
fetch_data if validate_config?
|
|
84
|
+
@data
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Reset received data
|
|
88
|
+
#
|
|
89
|
+
# @return [Boolean] Always true
|
|
90
|
+
#
|
|
91
|
+
def clear
|
|
92
|
+
@data = nil
|
|
93
|
+
@data_time = nil
|
|
94
|
+
@data_size = nil
|
|
95
|
+
@errors = ActiveModel::Errors.new(self)
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
alias_method :reset, :clear
|
|
99
|
+
|
|
100
|
+
# Reload provided data
|
|
101
|
+
#
|
|
102
|
+
# @return [String] Raw data
|
|
103
|
+
#
|
|
104
|
+
def reload
|
|
105
|
+
clear
|
|
106
|
+
fetch_data if validate_config?
|
|
107
|
+
@data
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Does the provider have what it needs for fetching data?
|
|
111
|
+
#
|
|
112
|
+
# @return [Boolean]
|
|
113
|
+
#
|
|
114
|
+
def valid?
|
|
115
|
+
validate
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Subclass should assign +@data+ with raw input, usually a string
|
|
121
|
+
#
|
|
122
|
+
# @abstract
|
|
123
|
+
#
|
|
124
|
+
def fetch_data
|
|
125
|
+
raise NoMethodError, "#{self.class}#fetch_data needs implementation"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Use errors.add(:attribute, "Message") provided by ActiveModel
|
|
129
|
+
# for validating required provider parameters before attempting
|
|
130
|
+
# to make connections and retrieve data.
|
|
131
|
+
#
|
|
132
|
+
# @abstract
|
|
133
|
+
#
|
|
134
|
+
def validate
|
|
135
|
+
raise NoMethodError, "#{self.class}#validate needs implementation"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_config?
|
|
139
|
+
unless validation = valid?
|
|
140
|
+
logger.error do
|
|
141
|
+
"Invalid #{self.class} provider configuration: #{errors.full_messages}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
validation
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Required by ActiveModel::Errors
|
|
148
|
+
def read_attribute_for_validation(attr)
|
|
149
|
+
send(attr)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Required by ActiveModel::Errors
|
|
153
|
+
def self.human_attribute_name(attr, options = {})
|
|
154
|
+
attr
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Required by ActiveModel::Errors
|
|
158
|
+
def self.lookup_ancestors
|
|
159
|
+
[self]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# When picking files from a list you can supply +:first+ or +:last+ to the
|
|
163
|
+
# provider's +pick+ option, or else a block that can reduce to a single
|
|
164
|
+
# value, like:
|
|
165
|
+
#
|
|
166
|
+
# proc do |best_match, current_match|
|
|
167
|
+
# current_match.better_than?(best_match) ?
|
|
168
|
+
# current_match : best_match
|
|
169
|
+
# end
|
|
170
|
+
#
|
|
171
|
+
def pick_from(list, &block)
|
|
172
|
+
case @pick
|
|
173
|
+
when Symbol
|
|
174
|
+
list.public_send @pick
|
|
175
|
+
when Proc
|
|
176
|
+
list.reduce &@pick
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @!macro [new] provider.pick_validation
|
|
183
|
+
# This validation option is applied after a matching file is picked.
|
|
184
|
+
|
|
185
|
+
# @!macro [new] provider.pick_option
|
|
186
|
+
# @group Options
|
|
187
|
+
#
|
|
188
|
+
# @!attribute [rw] pick
|
|
189
|
+
# Method for choosing which file to process from potential matches.
|
|
190
|
+
# @example
|
|
191
|
+
# pick :last
|
|
192
|
+
# pick :first
|
|
193
|
+
# pick ->(list) {
|
|
194
|
+
# list.max_by { |name| Time.strptime(name[/\d+/], "%m%d%Y").to_i }
|
|
195
|
+
# }
|
|
196
|
+
|
|
197
|
+
# @!macro [new] provider.file_options
|
|
198
|
+
# @group Options
|
|
199
|
+
#
|
|
200
|
+
# @!attribute [rw] file_name
|
|
201
|
+
# A string (glob) or regular expression matching files. E.g. one of:
|
|
202
|
+
# @return [String, Regexp]
|
|
203
|
+
# @example
|
|
204
|
+
# file_name "export-latest.csv"
|
|
205
|
+
# file_name "export-*.csv"
|
|
206
|
+
# file_name /export-\d{4}-\d{2}-\d{2}.csv/
|
|
207
|
+
#
|
|
208
|
+
# @!attribute [rw] file_dir
|
|
209
|
+
# Path where data files can be found. This should be an absolute path.
|
|
210
|
+
# @return [String]
|
|
211
|
+
# @example
|
|
212
|
+
# file_dir "/data"
|
|
213
|
+
#
|
|
214
|
+
# @!attribute [rw] file_newer
|
|
215
|
+
# Validates that the file to be processed is recent enough. To guard
|
|
216
|
+
# against processing an old file (even if it's the latest one), this should
|
|
217
|
+
# be set to the frequency you expect to receive new files for periodic
|
|
218
|
+
# processing.
|
|
219
|
+
# @macro provider.pick_validation
|
|
220
|
+
# @return [Time, Date]
|
|
221
|
+
# @example
|
|
222
|
+
# since Date.today
|
|
223
|
+
#
|
|
224
|
+
# @!attribute [rw] file_smaller
|
|
225
|
+
# Validates the maximum data size for the matched file, in bytes
|
|
226
|
+
# @return [Fixnum]
|
|
227
|
+
# @macro provider.pick_validation
|
|
228
|
+
# @example
|
|
229
|
+
# file_smaller 1024^3
|
|
230
|
+
#
|
|
231
|
+
# @!attribute [rw] file_larger
|
|
232
|
+
# Validates the minimum file size for the matched file, in bytes. This can # help guard against processing zero-byte or truncated files.
|
|
233
|
+
# @return [Fixnum]
|
|
234
|
+
# @macro provider.pick_validation
|
|
235
|
+
# @example
|
|
236
|
+
# file_larger 1024
|
|
237
|
+
|
|
238
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require 'stockboy/provider'
|
|
2
|
+
|
|
3
|
+
module Stockboy::Providers
|
|
4
|
+
|
|
5
|
+
# Get data from a local file
|
|
6
|
+
#
|
|
7
|
+
# Allows for selecting the appropriate file to be read from the given
|
|
8
|
+
# directory by glob pattern or regex pattern. By default the +:last+ file in
|
|
9
|
+
# the list is used, but can be controlled by sorting and reducing with the
|
|
10
|
+
# {#pick} option.
|
|
11
|
+
#
|
|
12
|
+
# == Job template DSL
|
|
13
|
+
#
|
|
14
|
+
# provider :file do
|
|
15
|
+
# file_dir '/data'
|
|
16
|
+
# file_name /report-[0-9]+\.csv/
|
|
17
|
+
# pick ->(list) { list[-2] }
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
class File < Stockboy::Provider
|
|
21
|
+
|
|
22
|
+
# @!group Options
|
|
23
|
+
|
|
24
|
+
# @macro provider.file_options
|
|
25
|
+
dsl_attr :file_name
|
|
26
|
+
dsl_attr :file_dir
|
|
27
|
+
dsl_attr :file_newer, alias: :since
|
|
28
|
+
dsl_attr :file_smaller
|
|
29
|
+
dsl_attr :file_larger
|
|
30
|
+
|
|
31
|
+
# @macro provider.pick_option
|
|
32
|
+
dsl_attr :pick
|
|
33
|
+
|
|
34
|
+
# @!endgroup
|
|
35
|
+
|
|
36
|
+
# Initialize a File provider
|
|
37
|
+
#
|
|
38
|
+
def initialize(opts={}, &block)
|
|
39
|
+
super(opts, &block)
|
|
40
|
+
@file_dir = opts[:file_dir]
|
|
41
|
+
@file_name = opts[:file_name]
|
|
42
|
+
@file_newer = opts[:file_newer]
|
|
43
|
+
@file_smaller = opts[:file_smaller]
|
|
44
|
+
@file_larger = opts[:file_larger]
|
|
45
|
+
@pick = opts[:pick] || :last
|
|
46
|
+
DSL.new(self).instance_eval(&block) if block_given?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delete_data
|
|
50
|
+
raise Stockboy::OutOfSequence, "must confirm #matching_file or calling #data" unless picked_matching_file?
|
|
51
|
+
|
|
52
|
+
logger.info "Deleting file #{file_dir}/#{matching_file}"
|
|
53
|
+
::File.delete matching_file
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def matching_file
|
|
57
|
+
return @matching_file if @matching_file
|
|
58
|
+
files = case file_name
|
|
59
|
+
when Regexp
|
|
60
|
+
Dir.entries(file_dir)
|
|
61
|
+
.select { |i| i =~ file_name }
|
|
62
|
+
.map { |i| ::File.join(file_dir, i) }
|
|
63
|
+
when String
|
|
64
|
+
Dir[::File.join(file_dir, file_name)]
|
|
65
|
+
end
|
|
66
|
+
@matching_file = pick_file_from(files) if files.any?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def clear
|
|
70
|
+
super
|
|
71
|
+
@matching_file = nil
|
|
72
|
+
@data_size = nil
|
|
73
|
+
@data_time = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def fetch_data
|
|
79
|
+
errors.add(:base, "File #{file_name} not found") unless matching_file
|
|
80
|
+
data_file = ::File.new(matching_file, 'r') if matching_file
|
|
81
|
+
validate_file(data_file)
|
|
82
|
+
if valid?
|
|
83
|
+
logger.info "Getting file #{file_dir}/#{matching_file}"
|
|
84
|
+
@data = data_file.read
|
|
85
|
+
logger.info "Got file #{file_dir}/#{matching_file} (#{@data_size} bytes)"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate
|
|
90
|
+
errors.add_on_blank [:file_dir, :file_name]
|
|
91
|
+
errors.empty?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pick_file_from(list)
|
|
95
|
+
case @pick
|
|
96
|
+
when Symbol
|
|
97
|
+
list.public_send @pick
|
|
98
|
+
when Proc
|
|
99
|
+
list.detect &@pick
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def picked_matching_file?
|
|
104
|
+
!!@matching_file
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_file(data_file)
|
|
108
|
+
return errors.add :response, "No matching files" unless data_file
|
|
109
|
+
validate_file_newer(data_file)
|
|
110
|
+
validate_file_smaller(data_file)
|
|
111
|
+
validate_file_larger(data_file)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def validate_file_newer(data_file)
|
|
115
|
+
@data_time ||= data_file.mtime
|
|
116
|
+
if file_newer && @data_time < file_newer
|
|
117
|
+
errors.add :response, "No new files since #{file_newer}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_file_smaller(data_file)
|
|
122
|
+
@data_size ||= data_file.size
|
|
123
|
+
if file_smaller && @data_size > file_smaller
|
|
124
|
+
errors.add :response, "File size larger than #{file_smaller}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_file_larger(data_file)
|
|
129
|
+
@data_size ||= data_file.size
|
|
130
|
+
if file_larger && @data_size < file_larger
|
|
131
|
+
errors.add :response, "File size smaller than #{file_larger}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
require 'stockboy/provider'
|
|
2
|
+
require 'net/ftp'
|
|
3
|
+
|
|
4
|
+
module Stockboy::Providers
|
|
5
|
+
|
|
6
|
+
# Get data from a remote FTP server
|
|
7
|
+
#
|
|
8
|
+
# Allows for selecting the appropriate file to be read from the given
|
|
9
|
+
# directory by glob pattern or regex pattern (glob string is more efficient
|
|
10
|
+
# for listing files from FTP). By default the +:last+ file in the list is
|
|
11
|
+
# used, but can be controlled by sorting and reducing with the {#pick}
|
|
12
|
+
# option.
|
|
13
|
+
#
|
|
14
|
+
# == Job template DSL
|
|
15
|
+
#
|
|
16
|
+
# provider :ftp do
|
|
17
|
+
# host 'ftp.example.com'
|
|
18
|
+
# username 'example'
|
|
19
|
+
# password '424242'
|
|
20
|
+
# file_dir 'data/daily'
|
|
21
|
+
# file_name /report-[0-9]+\.csv/
|
|
22
|
+
# pick ->(list) { list[-2] }
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
class FTP < Stockboy::Provider
|
|
26
|
+
|
|
27
|
+
# @!group Options
|
|
28
|
+
|
|
29
|
+
# Host name or IP address for FTP server connection
|
|
30
|
+
#
|
|
31
|
+
# @!attribute [rw] host
|
|
32
|
+
# @return [String]
|
|
33
|
+
# @example
|
|
34
|
+
# host "ftp.example.com"
|
|
35
|
+
#
|
|
36
|
+
dsl_attr :host
|
|
37
|
+
|
|
38
|
+
# Use a passive or active connection
|
|
39
|
+
#
|
|
40
|
+
# @!attribute [rw] passive
|
|
41
|
+
# @return [Boolean]
|
|
42
|
+
# @example
|
|
43
|
+
# passive true
|
|
44
|
+
#
|
|
45
|
+
dsl_attr :passive
|
|
46
|
+
|
|
47
|
+
# User name for connection credentials
|
|
48
|
+
#
|
|
49
|
+
# @!attribute [rw] username
|
|
50
|
+
# @return [String]
|
|
51
|
+
# @example
|
|
52
|
+
# username "arthur"
|
|
53
|
+
#
|
|
54
|
+
dsl_attr :username
|
|
55
|
+
|
|
56
|
+
# Password for connection credentials
|
|
57
|
+
#
|
|
58
|
+
# @!attribute [rw] password
|
|
59
|
+
# @return [String]
|
|
60
|
+
# @example
|
|
61
|
+
# password "424242"
|
|
62
|
+
#
|
|
63
|
+
dsl_attr :password
|
|
64
|
+
|
|
65
|
+
# Use binary mode for file transfers
|
|
66
|
+
#
|
|
67
|
+
# @!attribute [rw] binary
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
# @example
|
|
70
|
+
# binary true
|
|
71
|
+
#
|
|
72
|
+
dsl_attr :binary
|
|
73
|
+
|
|
74
|
+
# @macro provider.file_options
|
|
75
|
+
dsl_attr :file_name
|
|
76
|
+
dsl_attr :file_dir
|
|
77
|
+
dsl_attr :file_newer, alias: :since
|
|
78
|
+
dsl_attr :file_smaller
|
|
79
|
+
dsl_attr :file_larger
|
|
80
|
+
|
|
81
|
+
# @macro provider.pick_option
|
|
82
|
+
dsl_attr :pick
|
|
83
|
+
|
|
84
|
+
# @!endgroup
|
|
85
|
+
|
|
86
|
+
# Initialize a new FTP provider
|
|
87
|
+
#
|
|
88
|
+
def initialize(opts={}, &block)
|
|
89
|
+
super(opts, &block)
|
|
90
|
+
@host = opts[:host]
|
|
91
|
+
@passive = opts[:passive]
|
|
92
|
+
@username = opts[:username]
|
|
93
|
+
@password = opts[:password]
|
|
94
|
+
@binary = opts[:binary]
|
|
95
|
+
@file_dir = opts[:file_dir]
|
|
96
|
+
@file_name = opts[:file_name]
|
|
97
|
+
@file_newer = opts[:file_newer]
|
|
98
|
+
@file_smaller = opts[:file_smaller]
|
|
99
|
+
@file_larger = opts[:file_larger]
|
|
100
|
+
@pick = opts[:pick] || :last
|
|
101
|
+
DSL.new(self).instance_eval(&block) if block_given?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def client
|
|
105
|
+
return yield @open_client if @open_client
|
|
106
|
+
|
|
107
|
+
Net::FTP.open(host, username, password) do |ftp|
|
|
108
|
+
ftp.binary = binary
|
|
109
|
+
ftp.passive = passive
|
|
110
|
+
ftp.chdir file_dir if file_dir
|
|
111
|
+
@open_client = ftp
|
|
112
|
+
response = yield ftp
|
|
113
|
+
@open_client = nil
|
|
114
|
+
response
|
|
115
|
+
end
|
|
116
|
+
rescue Net::FTPError => e
|
|
117
|
+
errors.add :response, e.message
|
|
118
|
+
logger.warn e.message
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def matching_file
|
|
122
|
+
return @matching_file if @matching_file
|
|
123
|
+
client do |ftp|
|
|
124
|
+
file_listing = ftp.nlst.sort
|
|
125
|
+
@matching_file = pick_from file_listing.select(&file_name_matcher)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def delete_data
|
|
130
|
+
raise Stockboy::OutOfSequence, "must confirm #matching_file or calling #data" unless picked_matching_file?
|
|
131
|
+
client do |ftp|
|
|
132
|
+
logger.info "FTP deleting file #{host} #{file_dir}/#{matching_file}"
|
|
133
|
+
ftp.delete matching_file
|
|
134
|
+
matching_file
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def clear
|
|
139
|
+
super
|
|
140
|
+
@matching_file = nil
|
|
141
|
+
@data_time = nil
|
|
142
|
+
@data_size = nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def fetch_data
|
|
148
|
+
client do |ftp|
|
|
149
|
+
validate_file(matching_file)
|
|
150
|
+
if valid?
|
|
151
|
+
logger.info "FTP getting file #{host} #{file_dir}/#{matching_file}"
|
|
152
|
+
@data = ftp.get(matching_file, nil)
|
|
153
|
+
logger.info "FTP got file #{host} #{file_dir}/#{matching_file} (#{@data_size} bytes)"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
!@data.nil?
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def picked_matching_file?
|
|
160
|
+
!!@matching_file
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate
|
|
164
|
+
errors.add_on_blank [:host, :file_name]
|
|
165
|
+
errors.empty?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def file_name_matcher
|
|
169
|
+
case file_name
|
|
170
|
+
when Regexp
|
|
171
|
+
->(i) { i =~ file_name }
|
|
172
|
+
when String
|
|
173
|
+
->(i) { ::File.fnmatch(file_name, i) }
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_file(data_file)
|
|
178
|
+
return errors.add :response, "No matching files" unless data_file
|
|
179
|
+
validate_file_newer(data_file)
|
|
180
|
+
validate_file_smaller(data_file)
|
|
181
|
+
validate_file_larger(data_file)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_file_newer(data_file)
|
|
185
|
+
@data_time ||= client { |ftp| ftp.mtime(data_file) }
|
|
186
|
+
if file_newer and @data_time < file_newer
|
|
187
|
+
errors.add :response, "No new files since #{file_newer}"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def validate_file_smaller(data_file)
|
|
192
|
+
@data_size ||= client { |ftp| ftp.size(data_file) }
|
|
193
|
+
if file_smaller and @data_size > file_smaller
|
|
194
|
+
errors.add :response, "File size larger than #{file_smaller}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def validate_file_larger(data_file)
|
|
199
|
+
@data_size ||= client { |ftp| ftp.size(data_file) }
|
|
200
|
+
if file_larger and @data_size < file_larger
|
|
201
|
+
errors.add :response, "File size smaller than #{file_larger}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|