isolator 0.8.0 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c40700b0b2f7e5025ed7cbfb80580ac9ee1516a1295146bf0fa9db1de6e2a7f
4
- data.tar.gz: 7d605d3790733c0b1bcd44e6188c30050900879042ef604950cc38b36d6214d0
3
+ metadata.gz: 529433e0e838e663f335a5fb493f51d10f16fbde2168c89be72e92c968709593
4
+ data.tar.gz: ef33db058e25204e615fae7ee945c738e516e8ae6d63bb93f9660e6e117cb632
5
5
  SHA512:
6
- metadata.gz: d3a05d4ccad1f80171a67f0f124cb2d37548803a5451338cb9d821c16042dc94c407f9a4a155597742268cbf8ee939594a166c49835f35f5f4d2ec4d1d9a0567
7
- data.tar.gz: a287d291ca6465f3c8acfc8cb8c1c5ba898315c6aae80156855153d13a3c7c763864d97127ef38b04331bb7c99b23b417783eada86afdf8c26282c694dfe22bd
6
+ metadata.gz: 5cb63aa9a96f5931425267193590550cac0614dd15d272bd7d33f7e2aeaad0b7b37a19f5a6f7d8617dce992e946b92c42c3df5829177cbd51b41d8919abdc8c5
7
+ data.tar.gz: b4997855a9d813a961491235097a42eee75d55598fb7d33d53bb4f7e3828f486ea0a2fb788edd028237e39518a2d8e962824b08a3f4cbed2ce3ac22acdfee9c3
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 0.9.0 (2023-05-18)
6
+
7
+ - Support keyword arguments to isolated method in Ruby 3.0. ([@Mange][])
8
+ - Raise an error when an ignore file does not parse to a hash. ([@bobbymcwho][])
9
+ - Log all filtered backtrace lines to the logger ([@bobbymcwho][])
10
+ - Add support for removing dynamic adapters. ([@Mange][])
11
+ - Allow aliases in .isolator_todo.yml and .isolator_ignore.yml ([@tomgi][])
12
+
5
13
  ## 0.8.0 (2021-12-29)
6
14
 
7
15
  - Drop Ruby 2.5 support.
@@ -99,3 +107,5 @@ This, for example, makes Isolator compatible with Rails multi-database apps.
99
107
  [@shivanshgaur]: https://github.com/shivanshgaur
100
108
  [@iiwo]: https://github.com/iiwo
101
109
  [@mquan]: https://github.com/mquan
110
+ [@bobbymcwho]: https://github.com/bobbymcwho
111
+ [@Mange]: https://github.com/Mange
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2018-2020 Vladimir Dementyev
3
+ Copyright (c) 2018-2023 Vladimir Dementyev
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -110,7 +110,7 @@ Isolator relies on [uniform_notifier][] to send custom notifications.
110
110
 
111
111
  ### Transactional tests support
112
112
 
113
- - Rails' baked-in [use_transactional_tests](api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html#class-ActiveRecord::FixtureSet-label-Transactional+Tests)
113
+ - Rails' baked-in [use_transactional_tests](https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html#class-ActiveRecord::FixtureSet-label-Transactional+Tests)
114
114
  - [database_cleaner](https://github.com/DatabaseCleaner/database_cleaner) gem. Make sure that you require isolator _after_ database_cleaner.
115
115
 
116
116
  ### Supported ORMs
@@ -159,6 +159,29 @@ Isolator.adapters.sidekiq.ignore_if { Thread.current[:sidekiq_postpone] }
159
159
 
160
160
  You can add as many _ignores_ as you want, the offense is registered iff all of them return false.
161
161
 
162
+
163
+ ### Using with sidekiq/testing
164
+
165
+ If you require sidekiq/testing in your tests after isolator is required then it will blow away isolator's hooks, so you need to require isolator after requiring sidekiq/testing.
166
+
167
+ If you're using Rails and want to use isolator in development and staging, then here is a way to do this.
168
+
169
+ ```ruby
170
+
171
+ # Gemfile
172
+ gem "isolator", require: false # so it delays loading till after sidekiq/testing
173
+
174
+ # config/initializers/isolator.rb
175
+ require "sidekiq/testing" if Rails.env.test?
176
+
177
+ unless Rails.env.production? # so we get it in staging too
178
+ require "isolator"
179
+ Isolator.configure do |config|
180
+ config.send_notifications = true # ...
181
+ end
182
+ end
183
+ ```
184
+
162
185
  ### Using with legacy Rails codebases
163
186
 
164
187
  If you already have a huge Rails project it can be tricky to turn Isolator on because you'll immediately get a lot of failed specs. If you want to fix detected issues one by one, you can list all of them in the special files `.isolator_todo.yml` and `.isolator_ignore.yml` in the following way:
@@ -169,6 +192,16 @@ sidekiq:
169
192
  - app/models/sales/**/*.rb
170
193
  ```
171
194
 
195
+ You can ignore the same files in multiple adapters using YML aliases in the following way:
196
+
197
+ ```
198
+ http_common: &http_common
199
+ - app/models/user.rb:20
200
+
201
+ http: *http_common
202
+ webmock: *http_common
203
+ ```
204
+
172
205
  All the exceptions raised in the listed lines will be ignored.
173
206
 
174
207
  The `.isolator_todo.yml` file is intended to point to the code that should be fixed later, and `.isolator_ignore.yml` points to the code that for some reasons is not expected to be fixed. (See https://github.com/palkan/isolator/issues/40)
@@ -205,9 +238,36 @@ Isolator.isolate :active_job,
205
238
  target: ActiveJob::Base,
206
239
  method_name: :enqueue,
207
240
  exception_class: Isolator::BackgroundJobError,
208
- details_message: ->(obj, _args) {
241
+ details_message: ->(obj) {
209
242
  "#{obj.class.name}(#{obj.arguments})"
210
243
  }
244
+
245
+ Isolator.isolate :promoter,
246
+ target: UserPromoter,
247
+ method_name: :call,
248
+ details_message: ->(obj_, args, kwargs) {
249
+ # UserPromoter.call(user, role, by: nil)
250
+ user, role = args
251
+ by = kwargs[:by]
252
+ "#{user.name} promoted to #{role} by #{by&.name || "system"})"
253
+ }
254
+ ```
255
+
256
+ Trying to register the same adapter name twice will raise an error. You can guard for it, or remove old adapters before in order to replace them.
257
+
258
+ ```ruby
259
+ unless Isolator.has_adapter?(:promoter)
260
+ Isolator.isolate(:promoter, *rest)
261
+ end
262
+ ```
263
+
264
+ ```ruby
265
+ # Handle code reloading
266
+ class Messager
267
+ end
268
+
269
+ Isolator.remove_adapter(:messager)
270
+ Isolator.isolate(:messager, target: Messager, **rest)
211
271
  ```
212
272
 
213
273
  You can also add some callbacks to be run before and after the transaction:
@@ -5,29 +5,39 @@ require "isolator/adapters/base"
5
5
  module Isolator
6
6
  # Builds adapter from provided params
7
7
  module AdapterBuilder
8
- def self.call(target: nil, method_name: nil, **options)
9
- adapter = Module.new do
10
- extend Isolator::Adapters::Base
8
+ class << self
9
+ def call(target: nil, method_name: nil, **options)
10
+ adapter = Module.new do
11
+ extend Isolator::Adapters::Base
11
12
 
12
- self.exception_class = options[:exception_class] if options.key?(:exception_class)
13
- self.exception_message = options[:exception_message] if options.key?(:exception_message)
14
- self.details_message = options[:details_message] if options.key?(:details_message)
13
+ self.exception_class = options[:exception_class] if options.key?(:exception_class)
14
+ self.exception_message = options[:exception_message] if options.key?(:exception_message)
15
+ self.details_message = options[:details_message] if options.key?(:details_message)
16
+ end
17
+
18
+ mod = build_mod(method_name, adapter)
19
+ if target && mod
20
+ target.prepend(mod)
21
+ adapter.define_singleton_method(:restore) do
22
+ mod.remove_method(method_name)
23
+ end
24
+ end
25
+
26
+ adapter
15
27
  end
16
28
 
17
- add_patch_method(adapter, target, method_name) if
18
- target && method_name
19
- adapter
20
- end
29
+ private
30
+
31
+ def build_mod(method_name, adapter)
32
+ return nil unless method_name
21
33
 
22
- def self.add_patch_method(adapter, base, method_name)
23
- mod = Module.new do
24
- define_method method_name do |*args, &block|
25
- adapter.notify(caller, self, *args)
26
- super(*args, &block)
34
+ Module.new do
35
+ define_method method_name do |*args, **kwargs, &block|
36
+ adapter.notify(caller, self, *args, **kwargs)
37
+ super(*args, **kwargs, &block)
38
+ end
27
39
  end
28
40
  end
29
-
30
- base.prepend mod
31
41
  end
32
42
  end
33
43
  end
@@ -4,7 +4,7 @@ Isolator.isolate :active_job,
4
4
  target: ActiveJob::Base,
5
5
  method_name: :enqueue,
6
6
  exception_class: Isolator::BackgroundJobError,
7
- details_message: ->(obj, _args) {
7
+ details_message: ->(obj) {
8
8
  "#{obj.class.name}" \
9
9
  "#{obj.arguments.any? ? " (#{obj.arguments.join(", ")})" : ""}"
10
10
  }
@@ -22,13 +22,13 @@ module Isolator
22
22
  @disabled == true
23
23
  end
24
24
 
25
- def notify(backtrace, obj, *args)
26
- return unless notify?(*args)
27
- Isolator.notify(exception: build_exception(obj, args), backtrace: backtrace)
25
+ def notify(backtrace, obj, *args, **kwargs)
26
+ return unless notify?(*args, **kwargs)
27
+ Isolator.notify(exception: build_exception(obj, args, kwargs), backtrace: backtrace)
28
28
  end
29
29
 
30
- def notify?(*args)
31
- enabled? && Isolator.enabled? && Isolator.within_transaction? && !ignored?(*args)
30
+ def notify?(*args, **kwargs)
31
+ enabled? && Isolator.enabled? && Isolator.within_transaction? && !ignored?(*args, **kwargs)
32
32
  end
33
33
 
34
34
  def ignore_if(&block)
@@ -39,17 +39,36 @@ module Isolator
39
39
  @ignores ||= []
40
40
  end
41
41
 
42
- def ignored?(*args)
43
- ignores.any? { |block| block.call(*args) }
42
+ def ignored?(*args, **kwargs)
43
+ ignores.any? { |block| block.call(*args, **kwargs) }
44
44
  end
45
45
 
46
46
  private
47
47
 
48
- def build_exception(obj, args)
48
+ def build_exception(obj, args, kwargs = {})
49
49
  klass = exception_class || Isolator::UnsafeOperationError
50
- details = details_message.call(obj, args) if details_message
50
+ details = build_details(obj, args, kwargs)
51
51
  klass.new(exception_message, details: details)
52
52
  end
53
+
54
+ def build_details(obj, args, kwargs)
55
+ return nil unless details_message
56
+
57
+ case details_message.arity
58
+ when 2, -2
59
+ # Older users of details_message expected only two arguments. Add
60
+ # kwargs hash as last argument, like in older Ruby.
61
+ details_message.call(obj, args + [kwargs])
62
+ when 3, -3
63
+ # New signature separates args from kwargs
64
+ details_message.call(obj, args, kwargs)
65
+ when 1
66
+ # Callback does not care about any args
67
+ details_message.call(obj)
68
+ else
69
+ raise "Unexpected arity (#{details_message.arity}) for #{details_message.inspect}"
70
+ end
71
+ end
53
72
  end
54
73
  end
55
74
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  adapter = Isolator.isolate :webmock,
4
4
  exception_class: Isolator::HTTPError,
5
- details_message: ->(obj, _args) {
5
+ details_message: ->(obj) {
6
6
  "#{obj.method.to_s.upcase} #{obj.uri}"
7
7
  }
8
8
 
@@ -3,7 +3,7 @@
3
3
  Isolator.isolate :mailer, target: Mail::Message,
4
4
  method_name: :deliver,
5
5
  exception_class: Isolator::MailerError,
6
- details_message: ->(obj, _args) {
6
+ details_message: ->(obj) {
7
7
  "From: #{obj.from}\n" \
8
8
  "To: #{obj.to}\n" \
9
9
  "Subject: #{obj.subject}"
@@ -3,16 +3,31 @@
3
3
  module Isolator
4
4
  # Handle ignoring isolator errors using a yml file
5
5
  class Ignorer
6
- TODO_PATH = ".isolator_todo.yml"
6
+ class ParseError < StandardError
7
+ def initialize(file_path, klass)
8
+ @file_path = file_path
9
+ @klass = klass
10
+ end
11
+
12
+ def message
13
+ "Unable to parse ignore config file #{@file_path}. Expected Hash, got #{@klass}."
14
+ end
15
+ end
7
16
 
8
17
  class << self
9
- def prepare(path: TODO_PATH, regex_string: "^.*(#ignores#):.*$")
18
+ def prepare(path:, regex_string: "^.*(#ignores#):.*$")
10
19
  return unless File.exist?(path)
11
20
 
12
- todos = YAML.load_file(path)
21
+ ignores = begin
22
+ YAML.load_file(path, aliases: true)
23
+ rescue ArgumentError # support for older rubies https://github.com/rails/rails/commit/179d0a1f474ada02e0030ac3bd062fc653765dbe
24
+ YAML.load_file(path)
25
+ end
26
+
27
+ raise ParseError.new(path, ignores.class) unless ignores.respond_to?(:fetch)
13
28
 
14
29
  Isolator.adapters.each do |id, adapter|
15
- ignored_paths = todos.fetch(id, [])
30
+ ignored_paths = ignores.fetch(id, [])
16
31
  AdapterIgnore.new(adapter: adapter, ignored_paths: ignored_paths, regex_string: regex_string).prepare
17
32
  end
18
33
  end
@@ -4,9 +4,15 @@ module Isolator
4
4
  # Add .isolate function to build and register adapters
5
5
  module Isolate
6
6
  def isolate(id, **options)
7
- raise "Adapter already registered: #{id}" if Isolator.adapters.key?(id.to_s)
7
+ raise "Adapter already registered: #{id}" if Isolator.has_adapter?(id)
8
8
  adapter = AdapterBuilder.call(**options)
9
9
  Isolator.adapters[id.to_s] = adapter
10
10
  end
11
+
12
+ def remove_adapter(id)
13
+ if (adapter = Isolator.adapters.delete(id.to_s))
14
+ adapter.restore if adapter.respond_to?(:restore)
15
+ end
16
+ end
11
17
  end
12
18
  end
@@ -29,12 +29,12 @@ module Isolator
29
29
  def log_exception
30
30
  return unless Isolator.config.logger
31
31
 
32
- offense_line = filtered_backtrace.first
33
-
34
32
  msg = "[ISOLATOR EXCEPTION]\n" \
35
33
  "#{exception.message}"
36
34
 
37
- msg += "\n ↳ #{offense_line}" if offense_line
35
+ filtered_backtrace.each do |offense_line|
36
+ msg += "\n ↳ #{offense_line}"
37
+ end
38
38
 
39
39
  Isolator.config.logger.warn(msg)
40
40
  end
@@ -15,7 +15,18 @@ module Isolator
15
15
  # (when all deps are likely to be loaded).
16
16
  load File.join(__dir__, "adapters.rb")
17
17
 
18
- Isolator.config.ignorer&.prepare
18
+ # Try to load Rails base classes to trigger their load hooks
19
+ begin
20
+ ::ActionMailer::Base
21
+ rescue NameError
22
+ end
23
+
24
+ begin
25
+ ::ActiveJob::Base
26
+ rescue NameError
27
+ end
28
+
29
+ Isolator.config.ignorer&.prepare(path: ".isolator_todo.yml")
19
30
  Isolator.config.ignorer&.prepare(path: ".isolator_ignore.yml")
20
31
 
21
32
  next unless Rails.env.test?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Isolator
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/isolator.rb CHANGED
@@ -174,6 +174,10 @@ module Isolator
174
174
  @adapters ||= Isolator::SimpleHashie.new
175
175
  end
176
176
 
177
+ def has_adapter?(id)
178
+ adapters.key?(id.to_s)
179
+ end
180
+
177
181
  def load_ignore_config(path)
178
182
  warn "[DEPRECATION] `load_ignore_config` is deprecated. Please use `Isolator::Ignorer.prepare` instead."
179
183
  Isolator::Ignorer.prepare(path: path)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: isolator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladimir Dementyev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-12-29 00:00:00.000000000 Z
11
+ date: 2023-05-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sniffer
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.3.1
19
+ version: 0.5.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: 0.3.1
26
+ version: 0.5.0
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: bundler
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -326,6 +326,7 @@ metadata:
326
326
  documentation_uri: http://github.com/palkan/isolator
327
327
  homepage_uri: http://github.com/palkan/isolator
328
328
  source_code_uri: http://github.com/palkan/isolator
329
+ funding_uri: https://github.com/sponsors/palkan
329
330
  post_install_message:
330
331
  rdoc_options: []
331
332
  require_paths:
@@ -341,7 +342,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
341
342
  - !ruby/object:Gem::Version
342
343
  version: '0'
343
344
  requirements: []
344
- rubygems_version: 3.2.22
345
+ rubygems_version: 3.4.8
345
346
  signing_key:
346
347
  specification_version: 4
347
348
  summary: Detect non-atomic interactions within DB transactions