duck_typer 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f51efa5736485f62f43d321e7e3d8d612081c6b5eca59bb66823d78cb38b5442
4
- data.tar.gz: b75e8517ce41187b4df7b9f5260aa8da119feec892f34bc79e6e2865bd90bce8
3
+ metadata.gz: e7c4910b3357c23523997ed6cbb76cc1add4824af460fe8845390f1f69cfe2e6
4
+ data.tar.gz: 4aea61000506666b3ce649e08398247c9ab72c75826cc9e4819e91471547f8dd
5
5
  SHA512:
6
- metadata.gz: 87660bdaeb1b662f9b8c2cdf9399d564c3444990d61fa61223dca973b12d21941575baebd91ff04e0b36e9a5bb2a0b1f56277ea4df43fdaf56d263eb3008b43a
7
- data.tar.gz: 0ca5ae8d8de916e313655c97555582f8dbd7bff499492bcd90288a4e9d2e2da4c70641cd94de47ef9872be5e1b838894c9f6193f4b739524a0502c60a56fabb1
6
+ metadata.gz: 48bcd0be970572a490e373064d3894bd174aaffc29b52a0c7b8beea030530b0661bde8221b01a0b681d3511478259cb7edfe858383def4a1e8f22f1afec36577
7
+ data.tar.gz: 923d8722ae7ac8fb7f86aa12292056014fb00f5328c2034d63ed8b3c87984a0cce8890a0983d48fb2148b7d9ff1eb68888079cbaac90d4a7a7ebf33c8b5d6156
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-05-15
4
+
5
+ ### Added
6
+ - `CanonicalInterfaceChecker`: compares every class in a list
7
+ against a single canonical reference rather than in consecutive
8
+ pairs; all failures are aggregated into one result
9
+ - `assert_canonical_interface_match` Minitest assertion
10
+ - `implement_canonical_interface` RSpec matcher
11
+ - `InterfaceSetup`: extracted shared checker initialization
12
+ (argument validation, object resolution, name inference) from
13
+ `BulkInterfaceChecker` and `CanonicalInterfaceChecker`
14
+ - `ResultFormatting`: extracted shared result formatting logic
15
+
16
+ ### Changed
17
+ - Improved test accuracy and coverage for
18
+ `BulkInterfaceChecker` and `InterfaceChecker`
19
+
3
20
  ## [0.5.0] - 2026-03-13
4
21
 
5
22
  ### Added
data/README.md CHANGED
@@ -168,6 +168,78 @@ assert_interfaces_match namespace: Payments
168
168
  Duck Typer will resolve the module's constants and infer the
169
169
  interface name from the module name when `name:` is not given.
170
170
 
171
+ #### Canonical interface
172
+
173
+ Use `assert_canonical_interface_match` when you have a reference
174
+ class and want to verify that every implementation conforms to it.
175
+ The canonical class comes first — following the `assert_equal
176
+ expected, actual` convention — followed by the list of classes to
177
+ check.
178
+
179
+ The recommended approach is to define the interface inline with
180
+ `Class.new`, so it lives next to the assertion and makes the
181
+ expected contract explicit. Duck Typer doesn't encourage
182
+ inheritance by default — your implementations can be completely
183
+ independent:
184
+
185
+ ```ruby
186
+ def test_payment_processors_implement_canonical_interface
187
+ interface = Class.new do
188
+ def charge(amount, currency:) = nil
189
+ def refund(transaction_id) = nil
190
+ end
191
+
192
+ assert_canonical_interface_match interface, [
193
+ StripeProcessor,
194
+ PaypalProcessor,
195
+ BraintreeProcessor
196
+ ]
197
+ end
198
+ ```
199
+
200
+ If you already have an existing class that defines the contract,
201
+ you can pass it directly:
202
+
203
+ ```ruby
204
+ def test_payment_processors_implement_canonical_interface
205
+ assert_canonical_interface_match PaymentGateway, [
206
+ StripeProcessor,
207
+ PaypalProcessor,
208
+ BraintreeProcessor
209
+ ]
210
+ end
211
+ ```
212
+
213
+ You can also pass a `namespace:` instead of a list:
214
+
215
+ ```ruby
216
+ assert_canonical_interface_match PaymentGateway, namespace: Payments
217
+ ```
218
+
219
+ When multiple classes fail, a single assertion reports all of them
220
+ at once:
221
+
222
+ ```
223
+ Expected all objects to implement compatible interfaces defined by
224
+ PaymentGateway, but the following method signatures differ:
225
+
226
+ StripeProcessor: charge(amount)
227
+ PaymentGateway: charge(amount, currency:)
228
+
229
+ PaypalProcessor: refund not defined
230
+ PaymentGateway: refund(transaction_id)
231
+ ```
232
+
233
+ The same `type:`, `methods:`, `strict:`, and `name:` options are
234
+ supported:
235
+
236
+ ```ruby
237
+ assert_canonical_interface_match PaymentGateway, [StripeProcessor, PaypalProcessor],
238
+ methods: %i[charge refund],
239
+ strict: true,
240
+ name: "PaymentProcessor"
241
+ ```
242
+
171
243
  ### RSpec
172
244
 
173
245
  Require the RSpec integration in your `spec_helper.rb`:
@@ -228,6 +300,60 @@ To check all classes in a module, pass it as a named subject:
228
300
  expect(namespace: Payments).to have_matching_interfaces
229
301
  ```
230
302
 
303
+ #### Canonical interface
304
+
305
+ Use `implement_canonical_interface` when you have a reference class
306
+ and want to verify that a list of classes conforms to it. The
307
+ recommended approach is to define the interface inline with
308
+ `Class.new` — Duck Typer doesn't encourage inheritance by default,
309
+ and your implementations can be completely independent:
310
+
311
+ ```ruby
312
+ RSpec.describe "payment processors" do
313
+ it "implement the canonical interface" do
314
+ interface = Class.new do
315
+ def charge(amount, currency:) = nil
316
+ def refund(transaction_id) = nil
317
+ end
318
+
319
+ expect([StripeProcessor, PaypalProcessor, BraintreeProcessor])
320
+ .to implement_canonical_interface(interface)
321
+ end
322
+ end
323
+ ```
324
+
325
+ If you already have an existing class that defines the contract,
326
+ you can pass it directly:
327
+
328
+ ```ruby
329
+ RSpec.describe "payment processors" do
330
+ it "implement the canonical interface" do
331
+ expect([StripeProcessor, PaypalProcessor, BraintreeProcessor])
332
+ .to implement_canonical_interface(PaymentGateway)
333
+ end
334
+ end
335
+ ```
336
+
337
+ You can also pass a namespace:
338
+
339
+ ```ruby
340
+ expect(namespace: Payments).to implement_canonical_interface(PaymentGateway)
341
+ ```
342
+
343
+ The same `type:`, `methods:`, `strict:`, and `name:` options are
344
+ supported:
345
+
346
+ ```ruby
347
+ expect([StripeProcessor, PaypalProcessor])
348
+ .to implement_canonical_interface(PaymentGateway,
349
+ methods: %i[charge refund],
350
+ strict: true,
351
+ name: "PaymentProcessor")
352
+ ```
353
+
354
+ When multiple classes fail, the failure message reports all of them
355
+ at once.
356
+
231
357
  #### Shared example
232
358
 
233
359
  If you prefer shared examples, register one in `spec_helper.rb`
@@ -1,39 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "interface_setup"
4
+
3
5
  module DuckTyper
4
6
  # Runs interface checks across all consecutive pairs of classes in a list.
5
7
  class BulkInterfaceChecker
6
8
  def initialize(objects = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
7
- raise ArgumentError, "cannot specify both objects and namespace" if objects && namespace
8
- raise ArgumentError, "objects or namespace is required" if objects.nil? && namespace.nil?
9
-
10
- @objects = resolve_objects(objects, namespace)
11
- raise ArgumentError, "more than one object is required" if @objects.size < 2
12
-
13
- name ||= namespace&.name
14
- @checker = InterfaceChecker.new(type:, methods:, strict:, name:)
9
+ @setup = InterfaceSetup.new(objects, namespace:, type:, methods:, strict:, name:, minimum: 2)
15
10
  end
16
11
 
17
12
  def call(&block)
18
- @objects.each_cons(2).map do |left, right|
19
- result = @checker.call(left, right)
13
+ @setup.objects.each_cons(2).map do |left, right|
14
+ result = @setup.checker.call(left, right)
20
15
  block&.call(result)
21
16
 
22
17
  result
23
18
  end
24
19
  end
25
-
26
- private
27
-
28
- def resolve_objects(objects, namespace)
29
- namespace ? resolve_namespace(namespace) : Array(objects)
30
- end
31
-
32
- def resolve_namespace(namespace)
33
- namespace
34
- .constants
35
- .map { |const| namespace.const_get(const) }
36
- .select { |const| const.is_a?(Module) }
37
- end
38
20
  end
39
21
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../result_formatting"
4
+
5
+ module DuckTyper
6
+ class CanonicalInterfaceChecker
7
+ class Result
8
+ include ResultFormatting
9
+
10
+ def initialize(canonical:, results:, name:, strict:)
11
+ @canonical = canonical
12
+ @results = results
13
+ @name = name
14
+ @strict = strict
15
+ end
16
+
17
+ def match?
18
+ @results.all?(&:match?)
19
+ end
20
+
21
+ def failure_message
22
+ return if match?
23
+
24
+ failing = @results.reject(&:match?)
25
+
26
+ <<~MSG
27
+ Expected all objects to implement compatible \
28
+ #{interface_label} defined by #{@canonical}, \
29
+ but the following method signatures differ:#{strict_note}
30
+
31
+ #{failing.map(&:diff_message).join("\n")}
32
+ MSG
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "canonical_interface_checker/result"
4
+ require_relative "interface_setup"
5
+
6
+ module DuckTyper
7
+ # Compares each class in a list against a single canonical reference class.
8
+ class CanonicalInterfaceChecker
9
+ def initialize(objects = nil, canonical:, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
10
+ @setup = InterfaceSetup.new(objects, namespace:, type:, methods:, strict:, name:, minimum: 1)
11
+ @canonical = canonical
12
+ end
13
+
14
+ def call
15
+ results = @setup.objects.map { |obj| @setup.checker.call(obj, @canonical) }
16
+
17
+ Result.new(canonical: @canonical, results:, name: @setup.name, strict: @setup.strict)
18
+ end
19
+ end
20
+ end
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../result_formatting"
4
+
3
5
  module DuckTyper
4
6
  class InterfaceChecker
5
7
  class Result
8
+ include ResultFormatting
9
+
6
10
  attr_reader :left, :right
7
11
 
8
12
  def initialize(left:, right:, match:, diff_message:, name:, strict:)
@@ -18,6 +22,10 @@ module DuckTyper
18
22
  @match.call
19
23
  end
20
24
 
25
+ def diff_message
26
+ @diff_message.call
27
+ end
28
+
21
29
  def failure_message
22
30
  return if match?
23
31
 
@@ -25,19 +33,9 @@ module DuckTyper
25
33
  Expected #{@left} and #{@right} to implement compatible \
26
34
  #{interface_label}, but the following method signatures differ:#{strict_note}
27
35
 
28
- #{@diff_message.call}
36
+ #{diff_message}
29
37
  MSG
30
38
  end
31
-
32
- private
33
-
34
- def interface_label
35
- @name ? %("#{@name}" interfaces) : "interfaces"
36
- end
37
-
38
- def strict_note
39
- @strict ? " (strict mode: positional argument names must match)" : ""
40
- end
41
39
  end
42
40
  end
43
41
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "interface_checker"
4
+
5
+ module DuckTyper
6
+ class InterfaceSetup
7
+ attr_reader :objects, :checker, :name, :strict
8
+
9
+ def initialize(objects, namespace:, type:, methods:, strict:, name:, minimum: 0)
10
+ raise ArgumentError, "cannot specify both objects and namespace" if objects && namespace
11
+ raise ArgumentError, "objects or namespace is required" if objects.nil? && namespace.nil?
12
+
13
+ @objects = resolve_objects(objects, namespace)
14
+ raise ArgumentError, "at least #{minimum} object(s) required" if @objects.size < minimum
15
+
16
+ name ||= namespace&.name
17
+ @name = name
18
+ @strict = strict
19
+ @checker = InterfaceChecker.new(type:, methods:, strict:, name:)
20
+ end
21
+
22
+ private
23
+
24
+ def resolve_objects(objects, namespace)
25
+ namespace ? resolve_namespace(namespace) : Array(objects)
26
+ end
27
+
28
+ def resolve_namespace(namespace)
29
+ namespace
30
+ .constants
31
+ .map { |const| namespace.const_get(const) }
32
+ .select { |const| const.is_a?(Module) }
33
+ end
34
+ end
35
+ end
@@ -14,5 +14,13 @@ module DuckTyper
14
14
  end
15
15
 
16
16
  alias_method :assert_duck_types_match, :assert_interfaces_match
17
+
18
+ def assert_canonical_interface_match(canonical, objects = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
19
+ checker = CanonicalInterfaceChecker
20
+ .new(objects, canonical:, namespace:, type:, methods:, strict:, name:)
21
+
22
+ result = checker.call
23
+ assert result.match?, result.failure_message
24
+ end
17
25
  end
18
26
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DuckTyper
4
+ module ResultFormatting
5
+ private
6
+
7
+ def interface_label
8
+ @name ? %("#{@name}" interfaces) : "interfaces"
9
+ end
10
+
11
+ def strict_note
12
+ @strict ? " (strict mode: positional argument names must match)" : ""
13
+ end
14
+ end
15
+ end
@@ -21,6 +21,23 @@ end
21
21
 
22
22
  RSpec::Matchers.alias_matcher :have_matching_duck_types, :have_matching_interfaces
23
23
 
24
+ RSpec::Matchers.define :implement_canonical_interface do |canonical, name: nil, type: :instance_methods, methods: nil, strict: false|
25
+ match do |actual|
26
+ namespace = actual.is_a?(Hash) ? actual[:namespace] : nil
27
+ objects = namespace ? nil : actual
28
+
29
+ checker = DuckTyper::CanonicalInterfaceChecker
30
+ .new(objects, canonical:, namespace:, type:, methods:, strict:, name:)
31
+
32
+ @result = checker.call
33
+ @result.match?
34
+ end
35
+
36
+ failure_message do
37
+ @result.failure_message
38
+ end
39
+ end
40
+
24
41
  module DuckTyper
25
42
  module RSpec
26
43
  def self.define_shared_example(name = "an interface")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DuckTyper
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/duck_typer.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "duck_typer/version"
4
4
  require_relative "duck_typer/interface_checker"
5
5
  require_relative "duck_typer/bulk_interface_checker"
6
+ require_relative "duck_typer/canonical_interface_checker"
6
7
 
7
8
  # DuckTyper enforces duck-typed interfaces in Ruby by comparing the public
8
9
  # method signatures of classes, surfacing mismatches through your test suite.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: duck_typer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thiago A. Silva
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-13 00:00:00.000000000 Z
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -28,11 +28,15 @@ files:
28
28
  - Rakefile
29
29
  - lib/duck_typer.rb
30
30
  - lib/duck_typer/bulk_interface_checker.rb
31
+ - lib/duck_typer/canonical_interface_checker.rb
32
+ - lib/duck_typer/canonical_interface_checker/result.rb
31
33
  - lib/duck_typer/interface_checker.rb
32
34
  - lib/duck_typer/interface_checker/result.rb
35
+ - lib/duck_typer/interface_setup.rb
33
36
  - lib/duck_typer/method_inspector.rb
34
37
  - lib/duck_typer/minitest.rb
35
38
  - lib/duck_typer/params_normalizer.rb
39
+ - lib/duck_typer/result_formatting.rb
36
40
  - lib/duck_typer/rspec.rb
37
41
  - lib/duck_typer/version.rb
38
42
  homepage: https://github.com/thoughtbot/duck_typer