shrine 1.3.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of shrine might be problematic. Click here for more details.

@@ -1,46 +1,65 @@
1
1
  class Shrine
2
2
  module Plugins
3
- # The migration_helpers plugin gives the model additional helper methods
4
- # which are convenient when doing attachment migrations.
3
+ # The migration_helpers plugin gives the attacher additional helper methods
4
+ # which are convenient when doing file migrations.
5
5
  #
6
- # plugin :migration_helpers
6
+ # By default additional methods are also added to the model which delegate
7
+ # to the underlying attacher. If you want to disable that, you can load the
8
+ # plugin with `delegate: false`:
7
9
  #
8
- # ## `<attachment>_cache` and `<attachment>_store`
10
+ # plugin :migration_helpers, delegate: false
9
11
  #
10
- # If your attachment's name is "avatar", the model will get `#avatar_cache`
11
- # and `#avatar_store` methods.
12
+ # ## `attachment_cache` and `attachment_store`
13
+ #
14
+ # These methods return cache and store uploaders used by the underlying
15
+ # attacher:
12
16
  #
13
- # user = User.new
14
17
  # user.avatar_cache #=> #<Shrine @storage_key=:cache @storage=#<Shrine::Storage::FileSystem @directory=public/uploads>>
15
18
  # user.avatar_store #=> #<Shrine @storage_key=:store @storage=#<Shrine::Storage::S3:0x007fb8343397c8 @bucket=#<Aws::S3::Bucket name="foo">>>
16
19
  #
17
- # ## `<attachment>_cached?` and `<attachment>_stored?`
20
+ # # attacher equivalents
21
+ # user.avatar_attacher.cache
22
+ # user.avatar_attacher.store
23
+ #
24
+ # ## `attachment_cached?` and `attachment_stored?`
18
25
  #
19
- # You can use these methods to check whether attachment exists and is
20
- # cached/stored:
26
+ # These methods return true if attachment exists and is cached/stored:
21
27
  #
22
28
  # user.avatar_cached? # user.avatar && user.avatar_cache.uploaded?(user.avatar)
23
29
  # user.avatar_stored? # user.avatar && user.avatar_store.uploaded?(user.avatar)
24
30
  #
25
- # ## `update_<attachment>`
31
+ # # attacher equivalents
32
+ # user.avatar_attacher.cached?
33
+ # user.avatar_attacher.stored?
26
34
  #
27
- # The model will also get `#update_avatar` method, which can be used when
28
- # doing attachment migrations. It will update the record's attachment with
29
- # the result of the passed in block.
35
+ # ## `update_attachment`
36
+ #
37
+ # This method updates the record's attachment with the result of the given
38
+ # block.
30
39
  #
31
40
  # user.update_avatar do |avatar|
32
41
  # user.avatar_store.upload(avatar) # saved to the record
33
42
  # end
34
43
  #
35
- # This will get triggered _only_ if the attachment is not nil and is
36
- # stored, and will get saved only if the current attachment hasn't changed
37
- # while executing the block. The result can be anything that responds to
38
- # `#to_json` and evaluates to uploaded files' data.
44
+ # # attacher equivalent
45
+ # user.avatar_attacher.update_stored { |avatar| }
46
+ #
47
+ # The block will get triggered _only_ if the attachment is present and not
48
+ # cached, *and* will save the record only if the record's attachment
49
+ # hasn't changed in the time it took to execute the block. This method is
50
+ # most useful for adding/removing versions and changing locations of files.
39
51
  module MigrationHelpers
52
+ def self.configure(uploader, options = {})
53
+ warn "The :delegate option in migration_helpers Shrine plugin will default to false in Shrine 2. To remove this warning, set :delegate explicitly." if !options.key?(:delegate)
54
+ uploader.opts[:migration_helpers_delegate] = options.fetch(:delegate, true)
55
+ end
56
+
40
57
  module AttachmentMethods
41
58
  def initialize(name)
42
59
  super
43
60
 
61
+ return if shrine_class.opts[:migration_helpers_delegate] == false
62
+
44
63
  module_eval <<-RUBY, __FILE__, __LINE__ + 1
45
64
  def update_#{name}(&block)
46
65
  #{name}_attacher.update_stored(&block)
@@ -25,7 +25,7 @@ class Shrine
25
25
  # deleted after upload.
26
26
  module Moving
27
27
  def self.configure(uploader, storages:)
28
- uploader.opts[:move_files_to_storages] = storages
28
+ uploader.opts[:moving_storages] = storages
29
29
  end
30
30
 
31
31
  module InstanceMethods
@@ -38,6 +38,7 @@ class Shrine
38
38
  if movable?(io, context)
39
39
  move(io, context)
40
40
  else
41
+ warn "The #{storage_key.inspect} Shrine storage doesn't support moving a #{io.inspect}. It is currently still deleted, but it won't be in Shrine 2."
41
42
  super
42
43
  io.delete if io.respond_to?(:delete)
43
44
  end
@@ -55,7 +56,7 @@ class Shrine
55
56
  end
56
57
 
57
58
  def move?(io, context)
58
- opts[:move_files_to_storages].include?(storage_key)
59
+ opts[:moving_storages].include?(storage_key)
59
60
  end
60
61
  end
61
62
  end
@@ -1,24 +1,15 @@
1
- require "thread/pool"
2
-
3
- Thread::Pool.abort_on_exception = true
1
+ require "thread"
4
2
 
5
3
  class Shrine
6
4
  module Plugins
7
- # The parallelize plugin parallelizes your uploads and deletes using the
8
- # [thread] gem.
5
+ # The parallelize plugin parallelizes uploads and deletes when handling
6
+ # versions, using threads.
9
7
  #
10
8
  # plugin :parallelize
11
9
  #
12
- # This plugin is generally only useful as an addition to the versions
13
- # plugin, where multiple files are being uploaded and deleted at once. Note
14
- # that it's not possible for this plugin to parallelize processing, but it
15
- # should be easy to do that manually.
16
- #
17
10
  # By default a pool of 3 threads will be used, but you can change that:
18
11
  #
19
12
  # plugin :parallelize, threads: 5
20
- #
21
- # [thread]: https://github.com/meh/ruby-thread
22
13
  module Parallelize
23
14
  def self.configure(uploader, threads: 3)
24
15
  uploader.opts[:parallelize_threads] = threads
@@ -36,20 +27,48 @@ class Shrine
36
27
  private
37
28
 
38
29
  def put(io, context)
39
- context[:thread_pool].process { super }
30
+ context[:thread_pool].enqueue { super }
40
31
  end
41
32
 
42
33
  def remove(uploaded_file, context)
43
- context[:thread_pool].process { super }
34
+ context[:thread_pool].enqueue { super }
44
35
  end
45
36
 
46
37
  # We initialize a thread pool with configured number of threads.
47
- def with_pool
48
- pool = Thread.pool(opts[:parallelize_threads])
38
+ def with_pool(&block)
39
+ pool = ThreadPool.new(opts[:parallelize_threads])
49
40
  result = yield pool
50
- pool.shutdown
41
+ pool.perform
51
42
  result
52
43
  end
44
+
45
+ class ThreadPool
46
+ def initialize(thread_count)
47
+ @thread_count = thread_count
48
+ @tasks = Queue.new
49
+ end
50
+
51
+ def enqueue(&task)
52
+ @tasks.enq(task)
53
+ end
54
+
55
+ def perform
56
+ threads = @thread_count.times.map { spawn_thread }
57
+ threads.each(&:join)
58
+ end
59
+
60
+ private
61
+
62
+ def spawn_thread
63
+ Thread.new do
64
+ Thread.current.abort_on_exception = true
65
+ loop do
66
+ task = @tasks.deq(true) rescue break
67
+ task.call
68
+ end
69
+ end
70
+ end
71
+ end
53
72
  end
54
73
  end
55
74
 
@@ -1,35 +1,3 @@
1
- require "json"
2
-
3
- class Shrine
4
- module Plugins
5
- # The restore_cached plugin ensures the cached file data hasn't been
6
- # tampered with, by restoring its metadata after assignment. The user can
7
- # tamper with the cached file data by modifying the hidden field before
8
- # submitting the form.
9
- #
10
- # Firstly the assignment is terminated if the cached file doesn't exist,
11
- # which can happen if the user changes the "id" or "storage" data. If the
12
- # cached file exists, the metadata is reextracted from the original file
13
- # and replaced with the potentially tampered with ones.
14
- #
15
- # plugin :restore_cached
16
- module RestoreCached
17
- module AttacherMethods
18
- private
19
-
20
- def assign_cached(value)
21
- uploaded_file = uploaded_file(value) do |file|
22
- next unless cache.uploaded?(file)
23
- return unless file.exists?
24
- real_metadata = cache.extract_metadata(file.to_io, context)
25
- file.metadata.update(real_metadata)
26
- end
27
-
28
- super(uploaded_file)
29
- end
30
- end
31
- end
32
-
33
- register_plugin(:restore_cached, RestoreCached)
34
- end
35
- end
1
+ warn "The restore_cached Shrine plugin has been renamed to \"restore_cached_data\". Loading the plugin through \"restore_cached\" will not work in Shrine 2."
2
+ require "shrine/plugins/restore_cached_data"
3
+ Shrine::Plugins.register_plugin(:restore_cached, Shrine::Plugins::RestoreCachedData)
@@ -0,0 +1,34 @@
1
+ class Shrine
2
+ module Plugins
3
+ # The restore_cached_data plugin ensures the cached file data hasn't been
4
+ # tampered with, by restoring the metadata after assignment. The user can
5
+ # tamper with the cached file data by modifying the hidden field before
6
+ # submitting the form.
7
+ #
8
+ # Firstly the assignment is terminated if the cached file doesn't exist,
9
+ # which can happen if the user changes the "id" or "storage" data. If the
10
+ # cached file exists, the metadata is reextracted from the original file
11
+ # and replaced with the potentially tampered with ones.
12
+ #
13
+ # plugin :restore_cached_data
14
+ module RestoreCachedData
15
+ module AttacherMethods
16
+ private
17
+
18
+ def assign_cached(value)
19
+ cached_file = uploaded_file(value) do |cached_file|
20
+ next unless cache.uploaded?(cached_file)
21
+ return unless cached_file.exists?
22
+ real_metadata = cache.extract_metadata(cached_file.to_io, context)
23
+ cached_file.metadata.update(real_metadata)
24
+ cached_file.close
25
+ end
26
+
27
+ super(cached_file)
28
+ end
29
+ end
30
+ end
31
+
32
+ register_plugin(:restore_cached_data, RestoreCachedData)
33
+ end
34
+ end
@@ -73,18 +73,12 @@ class Shrine
73
73
 
74
74
  # Updates the current attachment with the new one, unless the current
75
75
  # attachment has changed.
76
- def swap(uploaded_file)
77
- record.db.transaction do
78
- break if record.send("#{name}_data") != record.reload.send("#{name}_data")
79
- super
80
- end
81
- rescue ::Sequel::Error
82
- end
83
-
84
- # We save the record after updating, raising any validation errors.
85
76
  def update(uploaded_file)
86
- super
87
- record.save(raise_on_failure: true)
77
+ record.this
78
+ .where(:"#{name}_data" => record.send(:"#{name}_data"))
79
+ .update(:"#{name}_data" => uploaded_file.to_json)
80
+ record.reload
81
+ rescue ::Sequel::Error
88
82
  end
89
83
 
90
84
  # Support for Postgres JSON columns.
@@ -50,21 +50,23 @@ class Shrine
50
50
  def extract_dimensions(io)
51
51
  analyzer = opts[:dimensions_analyzer]
52
52
 
53
- if io.respond_to?(:width) && io.respond_to?(:height)
53
+ dimensions = if io.respond_to?(:width) && io.respond_to?(:height)
54
54
  [io.width, io.height]
55
55
  elsif analyzer.is_a?(Symbol)
56
56
  send(:"_extract_dimensions_with_#{analyzer}", io)
57
57
  else
58
58
  analyzer.call(io)
59
59
  end
60
+
61
+ io.rewind
62
+
63
+ dimensions
60
64
  end
61
65
 
62
66
  private
63
67
 
64
68
  def _extract_dimensions_with_fastimage(io)
65
- result = FastImage.size(io)
66
- io.rewind # https://github.com/sdsykes/fastimage/pull/66
67
- result
69
+ FastImage.size(io)
68
70
  end
69
71
  end
70
72
 
@@ -75,7 +75,7 @@ class Shrine
75
75
  # store_dimensions plugin.
76
76
  def validate_max_width(max, message: nil)
77
77
  raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:width)
78
- if get.width > max
78
+ if get.width && get.width > max
79
79
  errors << error_message(:max_width, message, max)
80
80
  end
81
81
  end
@@ -84,7 +84,7 @@ class Shrine
84
84
  # store_dimensions plugin.
85
85
  def validate_min_width(min, message: nil)
86
86
  raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:width)
87
- if get.width < min
87
+ if get.width && get.width < min
88
88
  errors << error_message(:min_width, message, min)
89
89
  end
90
90
  end
@@ -93,7 +93,7 @@ class Shrine
93
93
  # store_dimensions plugin.
94
94
  def validate_max_height(max, message: nil)
95
95
  raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:height)
96
- if get.height > max
96
+ if get.height && get.height > max
97
97
  errors << error_message(:max_height, message, max)
98
98
  end
99
99
  end
@@ -102,7 +102,7 @@ class Shrine
102
102
  # store_dimensions plugin.
103
103
  def validate_min_height(min, message: nil)
104
104
  raise Error, ":store_dimensions plugin is required" if !get.respond_to?(:height)
105
- if get.height < min
105
+ if get.height && get.height < min
106
106
  errors << error_message(:min_height, message, min)
107
107
  end
108
108
  end
@@ -62,6 +62,18 @@ class Shrine
62
62
  #
63
63
  # user.avatar_url(:medium) #=> "http://example.com/medium.jpg"
64
64
  #
65
+ # If you already have some versions processed in the foreground when a
66
+ # background job is kicked off, you can setup explicit URL fallbacks:
67
+ #
68
+ # plugin :versions,
69
+ # names: [:thumb, :thumb_2x, :large, :large_2x],
70
+ # fallbacks: {:thumb_2x => :thumb, :large_2x => :large}
71
+ #
72
+ # # ... (background job is kicked off)
73
+ #
74
+ # user.avatar_url(:thumb_2x) # returns :thumb URL until :thumb_2x becomes available
75
+ # user.avatar_url(:large_2x) # returns :large URL until :large_2x becomes available
76
+ #
65
77
  # Any additional options will be properly forwarded to the underlying
66
78
  # storage:
67
79
  #
@@ -75,7 +87,7 @@ class Shrine
75
87
  # end
76
88
  #
77
89
  # When deleting versions, any multi delete capabilities will be leveraged,
78
- # so when usingStorage::S3, deleting versions will issue only a single HTTP
90
+ # so when using Storage::S3, deleting versions will issue only a single HTTP
79
91
  # request. If you want to delete versions manually, you can use
80
92
  # `Shrine#delete`:
81
93
  #
@@ -86,8 +98,9 @@ class Shrine
86
98
  uploader.plugin :multi_delete
87
99
  end
88
100
 
89
- def self.configure(uploader, names:)
101
+ def self.configure(uploader, names:, fallbacks: {})
90
102
  uploader.opts[:version_names] = names
103
+ uploader.opts[:version_fallbacks] = fallbacks
91
104
  end
92
105
 
93
106
  module ClassMethods
@@ -95,25 +108,20 @@ class Shrine
95
108
  opts[:version_names]
96
109
  end
97
110
 
111
+ def version_fallbacks
112
+ opts[:version_fallbacks]
113
+ end
114
+
98
115
  # Checks that the identifier is a registered version.
99
116
  def version?(name)
100
117
  version_names.map(&:to_s).include?(name.to_s)
101
118
  end
102
119
 
103
- # Asserts that the hash doesn't contain any unknown versions.
104
- def versions!(hash)
105
- hash.select { |name, _| version?(name) or raise Error, "unknown version: #{name.inspect}" }
106
- end
107
-
108
- # Filters the hash to contain only the registered versions.
109
- def versions(hash)
110
- hash.select { |name, _| version?(name) }
111
- end
112
-
113
120
  # Converts a hash of data into a hash of versions.
114
121
  def uploaded_file(object, &block)
115
- if object.is_a?(Hash) && !object.key?("storage")
116
- versions(object).inject({}) do |result, (name, data)|
122
+ if (hash = object).is_a?(Hash) && !hash.key?("storage")
123
+ hash.inject({}) do |result, (name, data)|
124
+ next result if !version?(name)
117
125
  result.update(name.to_sym => uploaded_file(data, &block))
118
126
  end
119
127
  else
@@ -141,7 +149,8 @@ class Shrine
141
149
  def _store(io, context)
142
150
  if (hash = io).is_a?(Hash)
143
151
  raise Error, ":location is not applicable to versions" if context.key?(:location)
144
- self.class.versions!(hash).inject({}) do |result, (name, version)|
152
+ hash.inject({}) do |result, (name, version)|
153
+ raise Error, "unknown version: #{name.inspect}" if !self.class.version?(name)
145
154
  result.update(name => _store(version, version: name, **context))
146
155
  end
147
156
  else
@@ -166,9 +175,11 @@ class Shrine
166
175
  def url(version = nil, **options)
167
176
  if get.is_a?(Hash)
168
177
  if version
169
- raise Error, "unknown version: #{version.inspect}" if !shrine_class.version_names.include?(version)
178
+ raise Error, "unknown version: #{version.inspect}" if !shrine_class.version?(version)
170
179
  if file = get[version]
171
180
  file.url(**options)
181
+ elsif fallback = shrine_class.version_fallbacks[version]
182
+ url(fallback, **options)
172
183
  else
173
184
  default_url(**options, version: version)
174
185
  end
@@ -7,18 +7,21 @@ require "tempfile"
7
7
  class Shrine
8
8
  # Error which is thrown when Storage::Linter fails.
9
9
  class LintError < Error
10
- attr_reader :errors
11
-
12
- def initialize(errors)
13
- @errors = errors
14
- super(errors.to_s)
15
- end
16
10
  end
17
11
 
18
12
  module Storage
19
- # Checks if the storage conforms to Shrine's specification. If the check
20
- # fails, by default it raises a LintError, but you can also specify
21
- # `action: :warn`.
13
+ # Checks if the storage conforms to Shrine's specification.
14
+ #
15
+ # Shrine::Storage::Linter.new(storage).call
16
+ #
17
+ # If the check fails, by default it raises a `Shrine::LintError`, but you
18
+ # can also specify `action: :warn`:
19
+ #
20
+ # Shrine::Storage::Linter.new(storage, action: :warn).call
21
+ #
22
+ # You can also specify an IO factory which the storage will use:
23
+ #
24
+ # Shrine::Storage::Linter.new(storage).call(->{File.open("test/fixtures/image.jpg")})
22
25
  class Linter
23
26
  def self.call(*args)
24
27
  new(*args).call
@@ -27,68 +30,107 @@ class Shrine
27
30
  def initialize(storage, action: :error)
28
31
  @storage = storage
29
32
  @action = action
30
- @errors = []
31
33
  end
32
34
 
33
- def call(io_factory = ->{FakeIO.new("image")})
34
- storage.upload(io_factory.call, id = "foo.jpg", {"mime_type" => "image/jpeg"})
35
+ def call(io_factory = default_io_factory)
36
+ storage.upload(io_factory.call, id = "foo", {})
35
37
 
36
- file = storage.download(id)
37
- error! "#download doesn't return a Tempfile" if !file.is_a?(Tempfile)
38
- error! "#download returns an empty file" if file.read.empty?
38
+ lint_download(id)
39
+ lint_open(id)
40
+ lint_read(id)
41
+ lint_exists(id)
42
+ lint_url(id)
43
+ lint_stream(id) if storage.respond_to?(:stream)
44
+ lint_delete(id)
39
45
 
40
- error! "#open doesn't return a valid IO object" if !io?(storage.open(id))
41
- error! "#read returns an empty string" if storage.read(id).empty?
42
- error! "#exists? returns false for a file that was uploaded" if !storage.exists?(id)
43
- error! "#url doesn't return a string" if !storage.url(id, {}).is_a?(String)
46
+ if storage.respond_to?(:move)
47
+ uploaded_file = uploader.upload(io_factory.call, location: "bar")
48
+ lint_move(uploaded_file, "quux")
49
+ end
44
50
 
45
- if storage.respond_to?(:stream)
46
- content = ""
47
- storage.stream(id) { |chunk| content << chunk }
48
- error! "#stream does not yield any chunks" if content.empty?
51
+ if storage.respond_to?(:multi_delete)
52
+ storage.upload(io_factory.call, id = "baz")
53
+ lint_multi_delete(id)
49
54
  end
50
55
 
51
- storage.delete(id)
52
- error! "#exists? returns true for a file that was deleted" if storage.exists?(id)
56
+ storage.upload(io_factory.call, id = "quux")
57
+ lint_clear(id)
58
+ end
53
59
 
54
- if storage.respond_to?(:move)
55
- if storage.respond_to?(:movable?)
56
- error! "#movable? doesn't accept 2 arguments" if !(storage.method(:movable?).arity == 2)
57
- error! "#move doesn't accept 3 arguments" if !(storage.method(:move).arity == -3)
58
-
59
- uploaded_file = uploader.upload(io_factory.call, location: "bar.jpg")
60
-
61
- if storage.movable?(uploaded_file, "quux.jpg")
62
- storage.move(uploaded_file, id = "quux.jpg")
63
- error! "#exists? returns false for destination after #move" if !storage.exists?(id)
64
- error! "#exists? returns true for source after #move" if storage.exists?(uploaded_file.id)
65
- end
66
- else
67
- error! "responds to #move but doesn't respond to #movable?" if !storage.respond_to?(:movable?)
68
- end
60
+ def lint_download(id)
61
+ downloaded = storage.download(id)
62
+ error :download, "doesn't return a Tempfile" if !downloaded.is_a?(Tempfile)
63
+ error :download, "returns an empty IO object" if downloaded.read.empty?
64
+ end
65
+
66
+ def lint_open(id)
67
+ opened = storage.open(id)
68
+ error :open, "doesn't return a valid IO object" if !io?(opened)
69
+ error :open, "returns an empty IO object" if opened.read.empty?
70
+ end
71
+
72
+ def lint_read(id)
73
+ read = storage.read(id)
74
+ error :read, "doesn't return a string" if !read.is_a?(String)
75
+ error :read, "returns an empty string" if read.empty?
76
+ end
77
+
78
+ def lint_exists(id)
79
+ error :exists?, "returns false for a file that was uploaded" if !storage.exists?(id)
80
+ end
81
+
82
+ def lint_url(id)
83
+ # just assert #url exists, it isn't required to return anything
84
+ url = storage.url(id)
85
+ error :url, "should return either nil or a string" if !(url.nil? || url.is_a?(String))
86
+ end
87
+
88
+ def lint_stream(id)
89
+ streamed = storage.enum_for(:stream, id).to_a
90
+ chunks = streamed.map { |(chunk, _)| chunk }
91
+ content_length = Array(streamed[0])[1]
92
+
93
+ error :stream, "doesn't yield any chunks" if chunks.empty?
94
+ error :stream, "yielded chunks sum up to empty content" if chunks.inject("", :+).empty?
95
+
96
+ if Array(streamed.first).size == 2
97
+ error :stream, "yielded content length isn't a number" if !content_length.is_a?(Integer)
69
98
  end
99
+ end
70
100
 
71
- if storage.respond_to?(:multi_delete)
72
- storage.upload(io_factory.call, id = "foo.jpg")
73
- storage.multi_delete([id])
74
- error! "#exists? returns true for a file that was multi-deleted" if storage.exists?(id)
101
+ def lint_delete(id)
102
+ storage.delete(id)
103
+ error :delete, "file still #exists? after deleting" if storage.exists?(id)
104
+ end
105
+
106
+ def lint_move(uploaded_file, id)
107
+ if storage.movable?(uploaded_file, id)
108
+ storage.move(uploaded_file, id, {})
109
+ error :exists?, "returns false for destination after #move" if !storage.exists?(id)
110
+ error :exists?, "returns true for source after #move" if storage.exists?(uploaded_file.id)
75
111
  end
112
+ end
113
+
114
+ def lint_multi_delete(id)
115
+ storage.multi_delete([id])
116
+ error :exists?, "returns true for a file that was multi-deleted" if storage.exists?(id)
117
+ end
118
+
119
+ def lint_clear(id)
120
+ storage.clear!(:confirm)
121
+ error :clear!, "file still #exists? after clearing" if storage.exists?(id)
76
122
 
77
123
  begin
78
124
  storage.clear!
79
- error! "#clear! should raise Shrine::Confirm unless :confirm is passed in"
125
+ error :clear!, "should raise Shrine::Confirm if :confirm is not passed in"
80
126
  rescue Shrine::Confirm
81
127
  end
82
-
83
- storage.upload(io_factory.call, id = "foo.jpg")
84
- storage.clear!(:confirm)
85
- error! "file still #exists? after #clear! was called" if storage.exists?(id)
86
-
87
- raise LintError.new(@errors) if @errors.any? && @action == :error
88
128
  end
89
129
 
90
130
  private
91
131
 
132
+ attr_reader :storage
133
+
92
134
  def uploader
93
135
  shrine = Class.new(Shrine)
94
136
  shrine.storages[:storage] = storage
@@ -96,18 +138,27 @@ class Shrine
96
138
  end
97
139
 
98
140
  def io?(object)
99
- missing_methods = IO_METHODS.reject do |m, a|
100
- object.respond_to?(m) && [a.count, -1].include?(object.method(m).arity)
141
+ uploader.send(:_enforce_io, object)
142
+ true
143
+ rescue Shrine::InvalidFile
144
+ false
145
+ end
146
+
147
+ def error(method_name, message)
148
+ if @action == :error
149
+ raise LintError, full_message(method_name, message)
150
+ else
151
+ warn full_message(method_name, message)
101
152
  end
102
- missing_methods.empty?
103
153
  end
104
154
 
105
- def error!(message)
106
- @errors << message
107
- warn(message) if @action.to_s.start_with?("warn")
155
+ def full_message(method_name, message)
156
+ "#{@storage.class}##{method_name} - #{message}"
108
157
  end
109
158
 
110
- attr_reader :storage
159
+ def default_io_factory
160
+ -> { FakeIO.new("file") }
161
+ end
111
162
 
112
163
  class FakeIO
113
164
  def initialize(content)