both_is_good 0.3.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 +4 -0
- data/.mdl_rules.rb +2 -0
- data/.mdlrc +2 -0
- data/.quiet_quality.yml +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +22 -0
- data/.standard.yml +1 -0
- data/LICENSE +20 -0
- data/README.md +144 -0
- data/both_is_good.gemspec +42 -0
- data/lib/both_is_good/configuration.rb +94 -0
- data/lib/both_is_good/implemented_twice.rb +12 -0
- data/lib/both_is_good/invocation.rb +113 -0
- data/lib/both_is_good/local_configuration.rb +33 -0
- data/lib/both_is_good/memoization.rb +23 -0
- data/lib/both_is_good/version.rb +3 -0
- data/lib/both_is_good.rb +111 -0
- metadata +164 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b0a0f01162c428c642c314b4abfcef3d918315c7816fc2b3ee1d31cdf276eb18
|
|
4
|
+
data.tar.gz: b8a4aa017737830cfe38dd8b47be2bca99d0b42258df9312c5f91dd410457ecd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 04f815942d66db366b44ec374ffabeba0094b1b64c41483b7a328ca4732191eae62075b2402fb4313bb7504468b5f0aac95e04f3e8029b906f4aedad618da5bd
|
|
7
|
+
data.tar.gz: a7a640ba54f642e98a0f67c06b3745cfe47a9b75b9bc5399691001e88139a6ef4282b96d9cb6b82cdbcf52cb1811a3b708b9b21eb8a84cc2edd680a90b59f4c5
|
data/.gitignore
ADDED
data/.mdl_rules.rb
ADDED
data/.mdlrc
ADDED
data/.quiet_quality.yml
ADDED
data/.rspec
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
--require spec_helper
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
AllCops:
|
|
3
|
+
SuggestExtensions: false
|
|
4
|
+
DisabledByDefault: true
|
|
5
|
+
TargetRubyVersion: 3.1
|
|
6
|
+
|
|
7
|
+
Metrics/AbcSize:
|
|
8
|
+
Max: 15
|
|
9
|
+
Metrics/CyclomaticComplexity:
|
|
10
|
+
Max: 8
|
|
11
|
+
Metrics/PerceivedComplexity:
|
|
12
|
+
Max: 7
|
|
13
|
+
|
|
14
|
+
Metrics/ClassLength:
|
|
15
|
+
CountComments: false
|
|
16
|
+
Max: 150
|
|
17
|
+
Metrics/MethodLength:
|
|
18
|
+
CountComments: false
|
|
19
|
+
Max: 15
|
|
20
|
+
Metrics/ParameterLists:
|
|
21
|
+
Max: 5
|
|
22
|
+
CountKeywordArgs: true
|
data/.standard.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ruby_version: 3.1
|
data/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2023 Eric Mueller
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# BothIsGood
|
|
2
|
+
|
|
3
|
+
This gem adds a module to include into classes, supplying a convenient, concise way
|
|
4
|
+
to implement multiple versions of the same method, and _run them both_. Then you
|
|
5
|
+
can still _use_ the old implementation, but get an alert or log message if the new
|
|
6
|
+
version ever produces a different result.
|
|
7
|
+
|
|
8
|
+
This is not a new concept; `scientist` pioneered the approach in 2016. But scientist
|
|
9
|
+
is moderately _heavy_, and takes significant effort to use, so I've ended up implementing
|
|
10
|
+
lightweight dual-implementation libraries multiple times; this time I'm publishing
|
|
11
|
+
it so I won't have to do so again later!
|
|
12
|
+
|
|
13
|
+
## Inline Invocation
|
|
14
|
+
|
|
15
|
+
The "simplest" way to use BothIsGood is 'inline' - no configuration object, you just
|
|
16
|
+
supply all of the needed options on the `implemented_twice` call in place.
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
include BothIsGood
|
|
20
|
+
|
|
21
|
+
def foo_one = implementation(details)
|
|
22
|
+
def foo_two = more_implementation(details)
|
|
23
|
+
|
|
24
|
+
# A minimal call. Note that with no global configuration this is not very valuable,
|
|
25
|
+
# since if the implementations disagree, there's no hook implemented to _tell you_ that.
|
|
26
|
+
implemented_twice(:foo, primary: :foo_one, secondary: :foo_two)
|
|
27
|
+
|
|
28
|
+
# A complex call using all of the available options:
|
|
29
|
+
implemented_twice(
|
|
30
|
+
:foo,
|
|
31
|
+
primary: :foo_one,
|
|
32
|
+
secondary: :foo_two,
|
|
33
|
+
rate: 0.01,
|
|
34
|
+
comparator: ->(val_one, val_two) { Math.abs(val_one - val_two) < 0.01 },
|
|
35
|
+
on_mismatch: ->(val_one, val_two) { LOGGER.warn("result mismatch on Foo#foo_one vs Foo#foo_two: #{val_one} | #{val_two}") },
|
|
36
|
+
on_compare: ->(val_one, val_two) { LOGGER.warn("comparing #{val_one} to #{val_two}") },
|
|
37
|
+
on_primary_error: ->(err, args) { LOGGER.warn("calling foo_one with #{args.to_json} produced error #{err.class.name}") },
|
|
38
|
+
on_secondary_error: ->(err, args) { LOGGER.warn("calling foo_two with #{args.to_json} produced error #{err.class.name}") },
|
|
39
|
+
on_hook_error: ->(err) { LOGGER.warn("OH NO! #{err.class.name}: #{err.message}") }
|
|
40
|
+
)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The method takes these parameters:
|
|
44
|
+
|
|
45
|
+
* The (only) positional parameter is the name of the method it will implement.
|
|
46
|
+
This _can_ match the 'primary' or 'secondary' name (but not both, obviously),
|
|
47
|
+
and if it does, `implemented_twice` will alias the existing method out of the
|
|
48
|
+
way (to `_bothisgood_primary_#{name}` or `_bothisgood_secondary_#{name}`).
|
|
49
|
+
* The `primary:` parameter specifies a method name that will be called _and have
|
|
50
|
+
its result used as the result of the final method_ regardless of the comparison
|
|
51
|
+
outcome. Errors from the primary method are bubbled up as usual.
|
|
52
|
+
* The `secondary:` parameter specifies a method name that will be called for
|
|
53
|
+
comparison's sake (though not necessarily every time). Errors raised from the
|
|
54
|
+
secondary method are swallowed.
|
|
55
|
+
* The `rate:` parameter (default 1.0) specifies what fraction of the calls should
|
|
56
|
+
bother evaluating the secondary implementation for comparison. If the
|
|
57
|
+
implementation is costly (makes significant database calls, for example) and/or
|
|
58
|
+
invoked frequently, you probably want a lower rate, at least in production.
|
|
59
|
+
* The `comparator:` parameter takes a callable, and yields two arguments to it
|
|
60
|
+
(the results of the two implementations); its result is either truthy or falsey.
|
|
61
|
+
By default, comparison is done using `==`.
|
|
62
|
+
* The `on_mismatch:` parameter takes a callable (lamba or Proc generally). It will
|
|
63
|
+
yield the two values being compared, but can yield additional details depending
|
|
64
|
+
on the arity of the callable; arities 2, 3, and 4 are all supported, and will be
|
|
65
|
+
yielded the arguments supplied, and then a Hash of the implementation method
|
|
66
|
+
names like `{primary: :foo_one, secondary: :foo_two}`. It will be invoked any
|
|
67
|
+
time the results of the two implementations _differ_.
|
|
68
|
+
* The `on_compare:` parameter takes the same shaped argument, but will be fired
|
|
69
|
+
any time both implementations are evaluated (so every time, unless `rate` is set)
|
|
70
|
+
* The `on_primary_error:` parameter takes a callable and yields 1, 2, or 3
|
|
71
|
+
arguments to it, depending on its arity - those arguments are the StandardError
|
|
72
|
+
instance rescued, the args supplied to the implementation (as an array,
|
|
73
|
+
potentially with a Hash arg at the end for any kwargs), and the name of the
|
|
74
|
+
primary method. The exception will be re-raised after handling.
|
|
75
|
+
* The `on_secondary_error:` parameter behaves identically (yielding the secondary
|
|
76
|
+
method name), but secondary exceptions are _not_ re-raised.
|
|
77
|
+
* The `on_hook_error:` parameter is a callable that will be yielded _one_
|
|
78
|
+
parameter (the StandardError instance), and is invoked if an error is _raised_
|
|
79
|
+
during one of the other hooks. None of us write bug-free code, and the callbacks
|
|
80
|
+
supplied to `implemented_twice` are no exception. Those errors will be swallowed
|
|
81
|
+
if `on_hook_error` is supplied (unless your hook raises the error!), but will be
|
|
82
|
+
bubbled otherwise.
|
|
83
|
+
|
|
84
|
+
`implemented_twice` can additionally be called with three positional parameters;
|
|
85
|
+
the second parameter is used as the `primary` method name, and the third parameter
|
|
86
|
+
is used as the `secondary` method name. That means that, if you use a configuration
|
|
87
|
+
object, you can just:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
include BothIsGood
|
|
91
|
+
|
|
92
|
+
def foo_one = implementation(details)
|
|
93
|
+
def foo_two = more_implementation(details)
|
|
94
|
+
|
|
95
|
+
# defines `foo`, using `foo_one` as the primary implementation and `foo_two` as secondary.
|
|
96
|
+
implemented_twice :foo, :foo_one, :foo_two
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If it is called with _two_ positional parameters, it will use the first argument
|
|
100
|
+
as both the final method name _and_ the primary implementation.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
include BothIsGood
|
|
104
|
+
|
|
105
|
+
def foo = implementation(details)
|
|
106
|
+
def foo_two = more_implementation(details)
|
|
107
|
+
|
|
108
|
+
# Defines `foo`, using `foo` as the primary implementation and `foo_two` as secondary.
|
|
109
|
+
# In the process, the original `foo` method is redefined as `_bothisgood_primary_foo`.
|
|
110
|
+
implemented_twice :foo, :foo_two
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Configuration
|
|
114
|
+
|
|
115
|
+
All of those parameters aside from the positional, `primary`, and `secondary` ones
|
|
116
|
+
can be configured globally, or onto a BothIsGood::Configuration object, to avoid
|
|
117
|
+
having to supply them constantly.
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# Global configuration
|
|
121
|
+
BothIsGood.configure do |config|
|
|
122
|
+
config.rate = 0.5
|
|
123
|
+
config.on_compare = ->(a, b) { LOGGER.puts "compared!" }
|
|
124
|
+
config.on_hook_error = ->(e) { LOGGER.puts "bad -.-" }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Local configuration - starting values are taken from the global config
|
|
128
|
+
MY_BIG_CONFIG = BothIsGood::Configuration.new
|
|
129
|
+
MY_BIG_CONFIG.rate = 0.7
|
|
130
|
+
MY_BIG_CONFIG.on_secondary_error = ->(a, b) { LOGGER.puts "No" }
|
|
131
|
+
|
|
132
|
+
module MyFoo
|
|
133
|
+
include BothIsGood
|
|
134
|
+
self.both_is_good_configure(MY_BIG_CONFIG)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# In-class configuration - starting values are taken from the global config, or the
|
|
139
|
+
# supplied config object if one is given.
|
|
140
|
+
module MyBar
|
|
141
|
+
include BothIsGood
|
|
142
|
+
self.both_is_good_configure(rate: 0.02)
|
|
143
|
+
end
|
|
144
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require_relative "lib/both_is_good/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "both_is_good"
|
|
5
|
+
spec.version = BothIsGood::VERSION
|
|
6
|
+
spec.authors = ["Eric Mueller"]
|
|
7
|
+
spec.email = ["nevinera@gmail.com"]
|
|
8
|
+
|
|
9
|
+
spec.summary = "A convenient way to give a method multiple implementations"
|
|
10
|
+
spec.description = <<~DESC
|
|
11
|
+
BothIsGood adds a clean way to run multiple implementations of a method,
|
|
12
|
+
switching between them using a feature-flagging system or other static or
|
|
13
|
+
runtime method, and potentially run multiple implementations to confirm
|
|
14
|
+
accuracy.
|
|
15
|
+
DESC
|
|
16
|
+
|
|
17
|
+
spec.homepage = "https://github.com/nevinera/both_is_good"
|
|
18
|
+
spec.license = "MIT"
|
|
19
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
20
|
+
|
|
21
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
25
|
+
`git ls-files -z`
|
|
26
|
+
.split("\x0")
|
|
27
|
+
.reject { |f| f.start_with?("spec") }
|
|
28
|
+
.reject { |f| f.start_with?("Gemfile") }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
spec.bindir = "bin"
|
|
32
|
+
spec.executables = []
|
|
33
|
+
spec.require_paths = ["lib"]
|
|
34
|
+
|
|
35
|
+
spec.add_development_dependency "rspec", "~> 3.13"
|
|
36
|
+
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
37
|
+
spec.add_development_dependency "pry", "~> 0.15"
|
|
38
|
+
spec.add_development_dependency "standard", "= 1.37.0"
|
|
39
|
+
spec.add_development_dependency "rubocop", "~> 1.63"
|
|
40
|
+
spec.add_development_dependency "quiet_quality", "~> 1.5"
|
|
41
|
+
spec.add_development_dependency "mdl", "~> 0.13"
|
|
42
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module BothIsGood
|
|
2
|
+
class Configuration
|
|
3
|
+
DEFAULTS = {
|
|
4
|
+
rate: 1.0,
|
|
5
|
+
on_mismatch: nil,
|
|
6
|
+
on_compare: nil,
|
|
7
|
+
on_primary_error: nil,
|
|
8
|
+
on_secondary_error: nil,
|
|
9
|
+
on_hook_error: nil
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
ATTRIBUTES = DEFAULTS.keys.freeze
|
|
13
|
+
UNSUPPLIED = Object.new.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader(*ATTRIBUTES)
|
|
16
|
+
|
|
17
|
+
def self.global
|
|
18
|
+
@global ||= new(nil, **DEFAULTS)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(supplied_base = UNSUPPLIED, **overrides)
|
|
22
|
+
base = base_config(supplied_base)
|
|
23
|
+
apply_initial_values(base, **overrides)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def dup = self.class.new(self)
|
|
27
|
+
|
|
28
|
+
def rate=(value)
|
|
29
|
+
unless value.is_a?(Numeric) && (0.0..1.0).cover?(value)
|
|
30
|
+
raise ArgumentError, "rate must be a number between 0.0 and 1.0, got #{value.inspect}"
|
|
31
|
+
end
|
|
32
|
+
@rate = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def on_mismatch=(value)
|
|
36
|
+
validate_hook!(:on_mismatch, value, [2, 3, 4])
|
|
37
|
+
@on_mismatch = value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def on_compare=(value)
|
|
41
|
+
validate_hook!(:on_compare, value, [2, 3, 4])
|
|
42
|
+
@on_compare = value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def on_primary_error=(value)
|
|
46
|
+
validate_hook!(:on_primary_error, value, [1, 2, 3])
|
|
47
|
+
@on_primary_error = value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def on_secondary_error=(value)
|
|
51
|
+
validate_hook!(:on_secondary_error, value, [1, 2, 3])
|
|
52
|
+
@on_secondary_error = value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on_hook_error=(value)
|
|
56
|
+
validate_hook!(:on_hook_error, value, [1])
|
|
57
|
+
@on_hook_error = value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def validate_hook!(name, value, valid_arities)
|
|
63
|
+
return if value.nil?
|
|
64
|
+
|
|
65
|
+
unless value.respond_to?(:call) && valid_arities.include?(value.arity)
|
|
66
|
+
raise ArgumentError, "#{name} must be nil or callable with arity in #{valid_arities.inspect}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def base_config(supplied_base)
|
|
71
|
+
if supplied_base == UNSUPPLIED
|
|
72
|
+
Configuration.global
|
|
73
|
+
else
|
|
74
|
+
supplied_base
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def initial_value_for(base, attr:, default:, overrides:)
|
|
79
|
+
if overrides.key?(attr)
|
|
80
|
+
overrides[attr]
|
|
81
|
+
elsif base
|
|
82
|
+
base.public_send(attr)
|
|
83
|
+
else
|
|
84
|
+
default
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def apply_initial_values(base, **overrides)
|
|
89
|
+
DEFAULTS.each_pair do |attr, default|
|
|
90
|
+
public_send(:"#{attr}=", initial_value_for(base, attr:, default:, overrides:))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module BothIsGood
|
|
2
|
+
class ImplementedTwice
|
|
3
|
+
def initialize(owner, primary:, secondary:, **opts)
|
|
4
|
+
base = owner.both_is_good_configuration
|
|
5
|
+
@local_config = LocalConfiguration.new(base, owner:, primary:, secondary:, **opts)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(target, *args, **kwargs)
|
|
9
|
+
Invocation.new(@local_config, target, args, kwargs).run
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module BothIsGood
|
|
2
|
+
class Invocation
|
|
3
|
+
include BothIsGood::Memoization
|
|
4
|
+
|
|
5
|
+
def initialize(local_config, target, args, kwargs)
|
|
6
|
+
@config = local_config
|
|
7
|
+
@target = target
|
|
8
|
+
@args = args
|
|
9
|
+
@kwargs = kwargs
|
|
10
|
+
@memo = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
invoke_primary!
|
|
15
|
+
invoke_secondary! if trigger?
|
|
16
|
+
primary_result
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
memoize def primary = @config.primary
|
|
22
|
+
memoize def secondary = @config.secondary
|
|
23
|
+
|
|
24
|
+
memoize def trigger? = rand < @config.rate
|
|
25
|
+
|
|
26
|
+
def invoke_primary!
|
|
27
|
+
primary_result
|
|
28
|
+
rescue => e
|
|
29
|
+
on_primary_error(e)
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def invoke_secondary!
|
|
34
|
+
secondary_result
|
|
35
|
+
rescue => e
|
|
36
|
+
on_secondary_error(e)
|
|
37
|
+
else
|
|
38
|
+
on_secondary_success
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
memoize def primary_result = @target.send(@config.primary, *@args, **@kwargs)
|
|
42
|
+
memoize def secondary_result = @target.send(@config.secondary, *@args, **@kwargs)
|
|
43
|
+
|
|
44
|
+
def on_primary_error(error)
|
|
45
|
+
hook = @config.on_primary_error
|
|
46
|
+
with_hook_error_handling { invoke_error_hook(hook, error, primary) } if hook
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def on_secondary_error(error)
|
|
50
|
+
return unless @config.on_secondary_error
|
|
51
|
+
|
|
52
|
+
with_hook_error_handling do
|
|
53
|
+
invoke_error_hook(@config.on_secondary_error, error, secondary)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def on_secondary_success
|
|
58
|
+
matched = compare(primary_result, secondary_result)
|
|
59
|
+
on_compare
|
|
60
|
+
on_mismatch unless matched
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def on_compare
|
|
64
|
+
return unless @config.on_compare
|
|
65
|
+
|
|
66
|
+
with_hook_error_handling do
|
|
67
|
+
invoke_result_hook(@config.on_compare, primary_result, secondary_result)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def on_mismatch
|
|
72
|
+
return unless @config.on_mismatch
|
|
73
|
+
|
|
74
|
+
with_hook_error_handling do
|
|
75
|
+
invoke_result_hook(@config.on_mismatch, primary_result, secondary_result)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def with_hook_error_handling
|
|
80
|
+
yield
|
|
81
|
+
rescue => e
|
|
82
|
+
if @config.on_hook_error
|
|
83
|
+
@config.on_hook_error.call(e)
|
|
84
|
+
else
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def compare(primary_result, secondary_result)
|
|
90
|
+
comparator = @config.comparator
|
|
91
|
+
comparator ? comparator.call(primary_result, secondary_result) : primary_result == secondary_result
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
memoize def names = {primary:, secondary:}
|
|
95
|
+
memoize def call_args = @kwargs.empty? ? @args : [*@args, @kwargs]
|
|
96
|
+
|
|
97
|
+
def invoke_result_hook(hook, primary_result, secondary_result)
|
|
98
|
+
case hook.arity
|
|
99
|
+
when 2 then hook.call(primary_result, secondary_result)
|
|
100
|
+
when 3 then hook.call(primary_result, secondary_result, names)
|
|
101
|
+
else hook.call(primary_result, secondary_result, call_args, names)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def invoke_error_hook(hook, error, method_name)
|
|
106
|
+
case hook.arity
|
|
107
|
+
when 1 then hook.call(error)
|
|
108
|
+
when 2 then hook.call(error, call_args)
|
|
109
|
+
else hook.call(error, call_args, method_name)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module BothIsGood
|
|
2
|
+
class LocalConfiguration < Configuration
|
|
3
|
+
LOCAL_ATTRIBUTES = %i[primary secondary comparator].freeze
|
|
4
|
+
|
|
5
|
+
attr_reader(*LOCAL_ATTRIBUTES)
|
|
6
|
+
|
|
7
|
+
def initialize(base_config, owner:, primary:, secondary:, **opts)
|
|
8
|
+
comparator = opts.delete(:comparator)
|
|
9
|
+
super(base_config, **opts)
|
|
10
|
+
@primary = validated_method(owner, :primary, primary)
|
|
11
|
+
@secondary = validated_method(owner, :secondary, secondary)
|
|
12
|
+
@comparator = validated_comparator(comparator)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def validated_method(owner, role, name)
|
|
18
|
+
raise ArgumentError, "#{role} must not be nil" if name.nil?
|
|
19
|
+
unless owner.method_defined?(name)
|
|
20
|
+
raise ArgumentError, "#{role} method #{name.inspect} is not defined on #{owner}"
|
|
21
|
+
end
|
|
22
|
+
name
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validated_comparator(value)
|
|
26
|
+
return nil if value.nil?
|
|
27
|
+
unless value.respond_to?(:call) && value.arity == 2
|
|
28
|
+
raise ArgumentError, "comparator must be nil or callable with arity 2"
|
|
29
|
+
end
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module BothIsGood
|
|
2
|
+
module Memoization
|
|
3
|
+
def self.included(base) = base.extend(ClassMethods)
|
|
4
|
+
|
|
5
|
+
module ClassMethods
|
|
6
|
+
def memoize(method_name)
|
|
7
|
+
original_method = instance_method(method_name)
|
|
8
|
+
|
|
9
|
+
define_method(method_name) do |*args|
|
|
10
|
+
raise ArgumentError, "Cannot memoize methods that take arguments" if args.any?
|
|
11
|
+
|
|
12
|
+
@memo ||= {}
|
|
13
|
+
|
|
14
|
+
if @memo.key?(method_name)
|
|
15
|
+
@memo[method_name]
|
|
16
|
+
else
|
|
17
|
+
@memo[method_name] = original_method.bind_call(self)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/both_is_good.rb
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require_relative "both_is_good/memoization"
|
|
2
|
+
|
|
3
|
+
module BothIsGood
|
|
4
|
+
def self.configuration = Configuration.global
|
|
5
|
+
|
|
6
|
+
def self.configure = yield(configuration)
|
|
7
|
+
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def both_is_good_configure(base = nil, **overrides)
|
|
14
|
+
@both_is_good_configuration =
|
|
15
|
+
if base
|
|
16
|
+
Configuration.new(base, **overrides)
|
|
17
|
+
else
|
|
18
|
+
Configuration.new(**overrides)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def both_is_good_configuration
|
|
23
|
+
@both_is_good_configuration || BothIsGood.configuration
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def implemented_twice(*positional, primary: nil, secondary: nil, **opts)
|
|
27
|
+
implementer = DualImplementer.new(*positional, target: self, primary:, secondary:, **opts)
|
|
28
|
+
implementer.apply_aliases!
|
|
29
|
+
runner = implementer.implementation
|
|
30
|
+
|
|
31
|
+
define_method(implementer.name) do |*args, **kwargs|
|
|
32
|
+
runner.call(self, *args, **kwargs)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class DualImplementer
|
|
38
|
+
include Memoization
|
|
39
|
+
|
|
40
|
+
def initialize(*positional, target:, primary:, secondary:, **opts)
|
|
41
|
+
@target = target
|
|
42
|
+
@positional = positional
|
|
43
|
+
@kw_primary = primary
|
|
44
|
+
@kw_secondary = secondary
|
|
45
|
+
@opts = opts
|
|
46
|
+
|
|
47
|
+
validate!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :positional, :target, :opts
|
|
51
|
+
|
|
52
|
+
memoize def name = positional.first
|
|
53
|
+
memoize def primary = aliased_primary? ? :"_bothisgood_primary_#{name}" : original_primary
|
|
54
|
+
memoize def secondary = aliased_secondary? ? :"_bothisgood_secondary_#{name}" : original_secondary
|
|
55
|
+
|
|
56
|
+
def apply_aliases!
|
|
57
|
+
target.alias_method(primary, name) if aliased_primary?
|
|
58
|
+
target.alias_method(secondary, name) if aliased_secondary?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
memoize def implementation = ImplementedTwice.new(target, primary:, secondary:, **opts)
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
memoize def original_primary = @kw_primary || positional[-2]
|
|
66
|
+
memoize def original_secondary = @kw_secondary || positional.last
|
|
67
|
+
|
|
68
|
+
memoize def aliased_primary? = original_primary == name
|
|
69
|
+
memoize def aliased_secondary? = original_secondary == name
|
|
70
|
+
|
|
71
|
+
def validate!
|
|
72
|
+
validate_name_supplied!
|
|
73
|
+
validate_secondary_supplied!
|
|
74
|
+
validate_no_primary_secondary_match!
|
|
75
|
+
validate_no_mixing!
|
|
76
|
+
validate_no_extra_positional!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_name_supplied!
|
|
80
|
+
return unless positional.empty?
|
|
81
|
+
raise(ArgumentError, "the 'name' positional parameter is required")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_secondary_supplied!
|
|
85
|
+
return if @kw_secondary || positional.length >= 2
|
|
86
|
+
raise ArgumentError, "secondary is required, either as a positional argument or as a keyword argument"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_no_primary_secondary_match!
|
|
90
|
+
return if original_primary != original_secondary
|
|
91
|
+
raise ArgumentError, "primary and secondary cannot be the same method"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_no_mixing!
|
|
95
|
+
return if positional.length <= 1
|
|
96
|
+
return if @kw_primary.nil? && @kw_secondary.nil?
|
|
97
|
+
raise ArgumentError, "cannot mix positional and keyword primary:/secondary:"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_no_extra_positional!
|
|
101
|
+
return if positional.length <= 3
|
|
102
|
+
raise ArgumentError, "implemented_twice takes at most 3 positional arguments"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
require_relative "both_is_good/version"
|
|
108
|
+
require_relative "both_is_good/configuration"
|
|
109
|
+
require_relative "both_is_good/local_configuration"
|
|
110
|
+
require_relative "both_is_good/invocation"
|
|
111
|
+
require_relative "both_is_good/implemented_twice"
|
metadata
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: both_is_good
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Eric Mueller
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-31 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rspec
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.13'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.13'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: simplecov
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.22'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.22'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: pry
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0.15'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0.15'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: standard
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - '='
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 1.37.0
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - '='
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 1.37.0
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '1.63'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '1.63'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: quiet_quality
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '1.5'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '1.5'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: mdl
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0.13'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0.13'
|
|
111
|
+
description: |
|
|
112
|
+
BothIsGood adds a clean way to run multiple implementations of a method,
|
|
113
|
+
switching between them using a feature-flagging system or other static or
|
|
114
|
+
runtime method, and potentially run multiple implementations to confirm
|
|
115
|
+
accuracy.
|
|
116
|
+
email:
|
|
117
|
+
- nevinera@gmail.com
|
|
118
|
+
executables: []
|
|
119
|
+
extensions: []
|
|
120
|
+
extra_rdoc_files: []
|
|
121
|
+
files:
|
|
122
|
+
- ".gitignore"
|
|
123
|
+
- ".mdl_rules.rb"
|
|
124
|
+
- ".mdlrc"
|
|
125
|
+
- ".quiet_quality.yml"
|
|
126
|
+
- ".rspec"
|
|
127
|
+
- ".rubocop.yml"
|
|
128
|
+
- ".standard.yml"
|
|
129
|
+
- LICENSE
|
|
130
|
+
- README.md
|
|
131
|
+
- both_is_good.gemspec
|
|
132
|
+
- lib/both_is_good.rb
|
|
133
|
+
- lib/both_is_good/configuration.rb
|
|
134
|
+
- lib/both_is_good/implemented_twice.rb
|
|
135
|
+
- lib/both_is_good/invocation.rb
|
|
136
|
+
- lib/both_is_good/local_configuration.rb
|
|
137
|
+
- lib/both_is_good/memoization.rb
|
|
138
|
+
- lib/both_is_good/version.rb
|
|
139
|
+
homepage: https://github.com/nevinera/both_is_good
|
|
140
|
+
licenses:
|
|
141
|
+
- MIT
|
|
142
|
+
metadata:
|
|
143
|
+
homepage_uri: https://github.com/nevinera/both_is_good
|
|
144
|
+
source_code_uri: https://github.com/nevinera/both_is_good
|
|
145
|
+
post_install_message:
|
|
146
|
+
rdoc_options: []
|
|
147
|
+
require_paths:
|
|
148
|
+
- lib
|
|
149
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
150
|
+
requirements:
|
|
151
|
+
- - ">="
|
|
152
|
+
- !ruby/object:Gem::Version
|
|
153
|
+
version: 3.1.0
|
|
154
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - ">="
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: '0'
|
|
159
|
+
requirements: []
|
|
160
|
+
rubygems_version: 3.5.22
|
|
161
|
+
signing_key:
|
|
162
|
+
specification_version: 4
|
|
163
|
+
summary: A convenient way to give a method multiple implementations
|
|
164
|
+
test_files: []
|