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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +5 -0
  4. data/.yardopts +7 -0
  5. data/CHANGELOG.md +24 -0
  6. data/Gemfile +12 -0
  7. data/Guardfile +10 -0
  8. data/LICENSE +21 -0
  9. data/README.md +293 -0
  10. data/Rakefile +30 -0
  11. data/lib/stockboy.rb +80 -0
  12. data/lib/stockboy/attribute.rb +11 -0
  13. data/lib/stockboy/attribute_map.rb +74 -0
  14. data/lib/stockboy/candidate_record.rb +130 -0
  15. data/lib/stockboy/configuration.rb +62 -0
  16. data/lib/stockboy/configurator.rb +176 -0
  17. data/lib/stockboy/dsl.rb +68 -0
  18. data/lib/stockboy/exceptions.rb +3 -0
  19. data/lib/stockboy/filter.rb +58 -0
  20. data/lib/stockboy/filter_chain.rb +41 -0
  21. data/lib/stockboy/filters.rb +11 -0
  22. data/lib/stockboy/filters/missing_email.rb +37 -0
  23. data/lib/stockboy/job.rb +241 -0
  24. data/lib/stockboy/mapped_record.rb +59 -0
  25. data/lib/stockboy/provider.rb +238 -0
  26. data/lib/stockboy/providers.rb +11 -0
  27. data/lib/stockboy/providers/file.rb +135 -0
  28. data/lib/stockboy/providers/ftp.rb +205 -0
  29. data/lib/stockboy/providers/http.rb +123 -0
  30. data/lib/stockboy/providers/imap.rb +290 -0
  31. data/lib/stockboy/providers/soap.rb +120 -0
  32. data/lib/stockboy/railtie.rb +28 -0
  33. data/lib/stockboy/reader.rb +59 -0
  34. data/lib/stockboy/readers.rb +11 -0
  35. data/lib/stockboy/readers/csv.rb +115 -0
  36. data/lib/stockboy/readers/fixed_width.rb +121 -0
  37. data/lib/stockboy/readers/spreadsheet.rb +144 -0
  38. data/lib/stockboy/readers/xml.rb +155 -0
  39. data/lib/stockboy/registry.rb +42 -0
  40. data/lib/stockboy/source_record.rb +43 -0
  41. data/lib/stockboy/string_pool.rb +35 -0
  42. data/lib/stockboy/template_file.rb +44 -0
  43. data/lib/stockboy/translations.rb +70 -0
  44. data/lib/stockboy/translations/boolean.rb +58 -0
  45. data/lib/stockboy/translations/date.rb +41 -0
  46. data/lib/stockboy/translations/decimal.rb +33 -0
  47. data/lib/stockboy/translations/default_empty_string.rb +38 -0
  48. data/lib/stockboy/translations/default_false.rb +41 -0
  49. data/lib/stockboy/translations/default_nil.rb +38 -0
  50. data/lib/stockboy/translations/default_true.rb +41 -0
  51. data/lib/stockboy/translations/default_zero.rb +41 -0
  52. data/lib/stockboy/translations/integer.rb +33 -0
  53. data/lib/stockboy/translations/string.rb +33 -0
  54. data/lib/stockboy/translations/time.rb +41 -0
  55. data/lib/stockboy/translations/uk_date.rb +51 -0
  56. data/lib/stockboy/translations/us_date.rb +51 -0
  57. data/lib/stockboy/translator.rb +66 -0
  58. data/lib/stockboy/version.rb +3 -0
  59. data/spec/fixtures/.gitkeep +0 -0
  60. data/spec/fixtures/files/a_garbage.csv +1 -0
  61. data/spec/fixtures/files/test_data-20120101.csv +1 -0
  62. data/spec/fixtures/files/test_data-20120202.csv +1 -0
  63. data/spec/fixtures/files/z_garbage.csv +1 -0
  64. data/spec/fixtures/jobs/test_job.rb +1 -0
  65. data/spec/fixtures/soap/get_list/fault.xml +8 -0
  66. data/spec/fixtures/soap/get_list/success.xml +18 -0
  67. data/spec/fixtures/spreadsheets/test_data.xls +0 -0
  68. data/spec/fixtures/spreadsheets/test_row_options.xls +0 -0
  69. data/spec/fixtures/xml/body.xml +14 -0
  70. data/spec/spec_helper.rb +28 -0
  71. data/spec/stockboy/attribute_map_spec.rb +59 -0
  72. data/spec/stockboy/attribute_spec.rb +11 -0
  73. data/spec/stockboy/candidate_record_spec.rb +150 -0
  74. data/spec/stockboy/configuration_spec.rb +28 -0
  75. data/spec/stockboy/configurator_spec.rb +127 -0
  76. data/spec/stockboy/filter_chain_spec.rb +40 -0
  77. data/spec/stockboy/filter_spec.rb +41 -0
  78. data/spec/stockboy/filters/missing_email_spec.rb +26 -0
  79. data/spec/stockboy/filters_spec.rb +38 -0
  80. data/spec/stockboy/job_spec.rb +238 -0
  81. data/spec/stockboy/mapped_record_spec.rb +30 -0
  82. data/spec/stockboy/provider_spec.rb +34 -0
  83. data/spec/stockboy/providers/file_spec.rb +116 -0
  84. data/spec/stockboy/providers/ftp_spec.rb +143 -0
  85. data/spec/stockboy/providers/http_spec.rb +94 -0
  86. data/spec/stockboy/providers/imap_spec.rb +76 -0
  87. data/spec/stockboy/providers/soap_spec.rb +107 -0
  88. data/spec/stockboy/providers_spec.rb +38 -0
  89. data/spec/stockboy/readers/csv_spec.rb +68 -0
  90. data/spec/stockboy/readers/fixed_width_spec.rb +52 -0
  91. data/spec/stockboy/readers/spreadsheet_spec.rb +121 -0
  92. data/spec/stockboy/readers/xml_spec.rb +94 -0
  93. data/spec/stockboy/readers_spec.rb +30 -0
  94. data/spec/stockboy/source_record_spec.rb +19 -0
  95. data/spec/stockboy/template_file_spec.rb +30 -0
  96. data/spec/stockboy/translations/boolean_spec.rb +48 -0
  97. data/spec/stockboy/translations/date_spec.rb +38 -0
  98. data/spec/stockboy/translations/decimal_spec.rb +23 -0
  99. data/spec/stockboy/translations/default_empty_string_spec.rb +32 -0
  100. data/spec/stockboy/translations/default_false_spec.rb +25 -0
  101. data/spec/stockboy/translations/default_nil_spec.rb +32 -0
  102. data/spec/stockboy/translations/default_true_spec.rb +25 -0
  103. data/spec/stockboy/translations/default_zero_spec.rb +32 -0
  104. data/spec/stockboy/translations/integer_spec.rb +22 -0
  105. data/spec/stockboy/translations/string_spec.rb +22 -0
  106. data/spec/stockboy/translations/time_spec.rb +27 -0
  107. data/spec/stockboy/translations/uk_date_spec.rb +37 -0
  108. data/spec/stockboy/translations/us_date_spec.rb +37 -0
  109. data/spec/stockboy/translations_spec.rb +55 -0
  110. data/spec/stockboy/translator_spec.rb +27 -0
  111. data/stockboy.gemspec +32 -0
  112. 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,11 @@
1
+ require 'stockboy/registry'
2
+
3
+ module Stockboy
4
+
5
+ # Registry of available providers
6
+ #
7
+ module Providers
8
+ extend Stockboy::Registry
9
+ end
10
+
11
+ 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