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 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
@@ -0,0 +1,4 @@
1
+ .ruby-version
2
+ .ruby-gemset
3
+ *.gem
4
+ coverage/
data/.mdl_rules.rb ADDED
@@ -0,0 +1,2 @@
1
+ all
2
+ rule "MD013", ignore_code_blocks: true
data/.mdlrc ADDED
@@ -0,0 +1,2 @@
1
+ style File.expand_path("../.mdl_rules.rb", __FILE__)
2
+ git_recurse true
@@ -0,0 +1,7 @@
1
+ ---
2
+ default_tools: ["standardrb", "rubocop", "markdown_lint", "rspec"]
3
+ executor: concurrent
4
+ comparison_branch: origin/main
5
+ logging: light
6
+ changed_files: false
7
+ filter_messages: false
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
@@ -0,0 +1,3 @@
1
+ module BothIsGood
2
+ VERSION = "0.3.0"
3
+ end
@@ -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: []