duck_typer 0.4.0 → 0.5.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: 881833f4db9f3c47049ad45e41c804a74a97db030438f43f933a388dc0f41cba
4
- data.tar.gz: ad0b846adb0389c1db0cd49d9d245cb6c006c136e76ea0e7b3980a8d1fb4d3ce
3
+ metadata.gz: f51efa5736485f62f43d321e7e3d8d612081c6b5eca59bb66823d78cb38b5442
4
+ data.tar.gz: b75e8517ce41187b4df7b9f5260aa8da119feec892f34bc79e6e2865bd90bce8
5
5
  SHA512:
6
- metadata.gz: be49c0476b7afe2c3df16fcede0480f242cfab68c0d1d0a637bba8ac4aa6070bada1c9bbd0350c556193340d62cc2a5665b9949b1c838840127574c2761ff57a
7
- data.tar.gz: e9f4543f7117f6e7e3709a153cb316ce90974a984fc302bec71dc84c78fff89a706585cffc9581a24b6f9fa679df3dd0f971a0a82b44c1b78644ff1a8948ba1e
6
+ metadata.gz: 87660bdaeb1b662f9b8c2cdf9399d564c3444990d61fa61223dca973b12d21941575baebd91ff04e0b36e9a5bb2a0b1f56277ea4df43fdaf56d263eb3008b43a
7
+ data.tar.gz: 0ca5ae8d8de916e313655c97555582f8dbd7bff499492bcd90288a4e9d2e2da4c70641cd94de47ef9872be5e1b838894c9f6193f4b739524a0502c60a56fabb1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] - 2026-03-13
4
+
5
+ ### Added
6
+ - `name:` option: includes the interface name in failure messages
7
+ (e.g. `compatible "Linkable" interfaces`)
8
+ - `namespace:` option: resolves a module's constants as the list of
9
+ objects to compare; infers the interface name from the module name
10
+ when `name:` is not given
11
+ - Strict mode now notes itself in the failure message:
12
+ `(strict mode: positional argument names must match)`
13
+ - `BulkInterfaceChecker` and `InterfaceChecker` raise
14
+ `PrivateMethodError` when a private method is specified in
15
+ `methods:`
16
+
17
+ ### Changed
18
+ - `partial_interface_methods:` renamed to `methods:` across
19
+ `BulkInterfaceChecker`, `InterfaceChecker`, `assert_interfaces_match`,
20
+ and `have_matching_interfaces` (breaking change)
21
+ - RSpec matcher now accepts a module as the subject via
22
+ `expect(namespace: MyModule).to have_matching_interfaces`
23
+ - RSpec shared example now accepts `namespace:` as a keyword argument
24
+
25
+ ### Fixed
26
+ - Numbered block parameters (`_1`) replaced with named ones to avoid
27
+ `Style/ItBlockParameter` lint failures on Ruby 3.4
28
+
3
29
  ## [0.4.0] - 2026-03-09
4
30
 
5
31
  ### Added
data/README.md CHANGED
@@ -1,18 +1,18 @@
1
- # DuckTyper
1
+ # Duck Typer
2
2
 
3
3
  [![CI](https://github.com/thoughtbot/duck_typer/actions/workflows/ci.yml/badge.svg)](https://github.com/thoughtbot/duck_typer/actions/workflows/ci.yml)
4
4
 
5
5
  <div align="center">
6
- <img alt="DuckTyper mascot" src="assets/swan_mugshot.png" width="300">
6
+ <img alt="Duck Typer mascot" src="assets/swan_mugshot.png" width="300">
7
7
  </div>
8
8
 
9
9
  > If it quacks like a duck, it's a duck... or is it?
10
10
 
11
- DuckTyper enforces duck-typed interfaces in Ruby by comparing the
11
+ Duck Typer enforces duck-typed interfaces in Ruby by comparing the
12
12
  public method signatures of classes, surfacing mismatches through
13
13
  your test suite.
14
14
 
15
- ## Why DuckTyper?
15
+ ## Why Duck Typer?
16
16
 
17
17
  Ruby is a duck-typed language. When multiple classes play the same
18
18
  role, what matters is not what they _are_, but what they _do_ — the
@@ -25,17 +25,17 @@ from its dynamic nature: abstract base classes that raise
25
25
  signatures, or inheritance hierarchies that couple unrelated
26
26
  classes. These work, but they're not very Ruby.
27
27
 
28
- DuckTyper takes a different approach. It compares public method
28
+ Duck Typer takes a different approach. It compares public method
29
29
  signatures directly and reports mismatches through your test suite —
30
30
  the natural place to enforce design constraints in Ruby. There's
31
31
  nothing to annotate and nothing to inherit from. The classes remain
32
- independent; DuckTyper simply verifies that they're speaking the
32
+ independent; Duck Typer simply verifies that they're speaking the
33
33
  same language. The interface itself needs no declaration — it is
34
34
  the intersection of methods your classes define in common, a living
35
35
  document that evolves naturally.
36
36
 
37
37
  It's also useful during active development. When an interface
38
- evolves, implementations can easily fall out of sync. DuckTyper
38
+ evolves, implementations can easily fall out of sync. Duck Typer
39
39
  catches that immediately and reports clear, precise error messages
40
40
  showing exactly which signatures diverged — keeping your classes
41
41
  aligned as the design changes.
@@ -56,7 +56,7 @@ bundle install
56
56
 
57
57
  ## Usage
58
58
 
59
- When interfaces don't match, DuckTyper reports the differing
59
+ When interfaces don't match, Duck Typer reports the differing
60
60
  signatures:
61
61
 
62
62
  ```
@@ -120,7 +120,7 @@ end
120
120
  > If you prefer duck typing terminology, `assert_duck_types_match`
121
121
  > is available as an alias.
122
122
 
123
- By default, DuckTyper checks instance method interfaces. To check
123
+ By default, Duck Typer checks instance method interfaces. To check
124
124
  class-level interfaces instead, pass `type: :class_methods`:
125
125
 
126
126
  ```ruby
@@ -151,6 +151,23 @@ count and kind (required, optional, rest) are compared. In strict
151
151
  mode, names must match exactly. Keyword argument names always
152
152
  matter regardless of this setting.
153
153
 
154
+ To include the interface name in failure messages, use `name:`:
155
+
156
+ ```ruby
157
+ assert_interfaces_match [StripeProcessor, PaypalProcessor],
158
+ name: "PaymentProcessor"
159
+ ```
160
+
161
+ If your classes are organized under a module, pass it with
162
+ `namespace:` instead of listing them explicitly:
163
+
164
+ ```ruby
165
+ assert_interfaces_match namespace: Payments
166
+ ```
167
+
168
+ Duck Typer will resolve the module's constants and infer the
169
+ interface name from the module name when `name:` is not given.
170
+
154
171
  ### RSpec
155
172
 
156
173
  Require the RSpec integration in your `spec_helper.rb`:
@@ -198,6 +215,19 @@ expect([StripeProcessor, PaypalProcessor])
198
215
  .to have_matching_interfaces(strict: true)
199
216
  ```
200
217
 
218
+ To include the interface name in failure messages, use `name:`:
219
+
220
+ ```ruby
221
+ expect([StripeProcessor, PaypalProcessor])
222
+ .to have_matching_interfaces(name: "PaymentProcessor")
223
+ ```
224
+
225
+ To check all classes in a module, pass it as a named subject:
226
+
227
+ ```ruby
228
+ expect(namespace: Payments).to have_matching_interfaces
229
+ ```
230
+
201
231
  #### Shared example
202
232
 
203
233
  If you prefer shared examples, register one in `spec_helper.rb`
@@ -226,7 +256,8 @@ RSpec.describe "payment processors" do
226
256
  end
227
257
  ```
228
258
 
229
- The same `type:`, `methods:`, and `strict:` options are supported:
259
+ The same `type:`, `methods:`, `strict:`, and `name:` options are
260
+ supported:
230
261
 
231
262
  ```ruby
232
263
  it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
@@ -235,9 +266,15 @@ it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
235
266
  strict: true
236
267
  ```
237
268
 
269
+ To check all classes in a module, pass it with `namespace:`:
270
+
271
+ ```ruby
272
+ it_behaves_like "an interface", namespace: Payments
273
+ ```
274
+
238
275
  ## Limitations
239
276
 
240
- By default, DuckTyper checks the **structure** of public method
277
+ By default, Duck Typer checks the **structure** of public method
241
278
  signatures — the number of parameters, their kinds (required,
242
279
  optional, keyword, rest, block), and keyword argument names. In
243
280
  strict mode, positional argument names are also compared. It does
@@ -245,7 +282,7 @@ not
245
282
  verify the following, which should be covered by your regular
246
283
  test suite:
247
284
 
248
- - **Parameter types.** DuckTyper only checks that both methods
285
+ - **Parameter types.** Duck Typer only checks that both methods
249
286
  declare an `amount` parameter — not what type of value it
250
287
  expects. Two methods with identical signatures may still be
251
288
  incompatible if they expect different types.
@@ -253,7 +290,7 @@ test suite:
253
290
  but return completely different things.
254
291
  - **Behavior.** Matching signatures are a necessary but not
255
292
  sufficient condition for duck typing to work correctly at
256
- runtime. DuckTyper catches structural drift, not semantic
293
+ runtime. Duck Typer catches structural drift, not semantic
257
294
  divergence.
258
295
 
259
296
  Some things are intentionally out of scope:
@@ -266,7 +303,7 @@ Some things are intentionally out of scope:
266
303
 
267
304
  ## Stability
268
305
 
269
- DuckTyper is intentionally minimal. It reflects Ruby's own method
306
+ Duck Typer is intentionally minimal. It reflects Ruby's own method
270
307
  introspection API, which rarely changes — so the gem rarely needs
271
308
  to either. When it does change, it will most likely be for additive reasons:
272
309
  new API options, better error messages, or broader test framework
@@ -295,7 +332,7 @@ Thank you, [contributors]!
295
332
 
296
333
  ## License
297
334
 
298
- DuckTyper is Copyright (c) thoughtbot, inc.
335
+ Duck Typer is Copyright (c) thoughtbot, inc.
299
336
  It is free software, and may be redistributed
300
337
  under the terms specified in the [LICENSE] file.
301
338
 
@@ -3,19 +3,37 @@
3
3
  module DuckTyper
4
4
  # Runs interface checks across all consecutive pairs of classes in a list.
5
5
  class BulkInterfaceChecker
6
- def initialize(objects, type: :instance_methods, partial_interface_methods: nil, strict: false)
7
- raise ArgumentError, "more than one class is required" if objects.size < 2
6
+ 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?
8
9
 
9
- @objects = objects
10
- @checker = InterfaceChecker.new(type:, partial_interface_methods:, strict:)
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:)
11
15
  end
12
16
 
13
17
  def call(&block)
14
18
  @objects.each_cons(2).map do |left, right|
15
19
  result = @checker.call(left, right)
16
20
  block&.call(result)
21
+
17
22
  result
18
23
  end
19
24
  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
20
38
  end
21
39
  end
@@ -5,11 +5,13 @@ module DuckTyper
5
5
  class Result
6
6
  attr_reader :left, :right
7
7
 
8
- def initialize(left:, right:, match:, diff_message:)
8
+ def initialize(left:, right:, match:, diff_message:, name:, strict:)
9
9
  @left = left
10
10
  @right = right
11
11
  @match = match
12
12
  @diff_message = diff_message
13
+ @name = name
14
+ @strict = strict
13
15
  end
14
16
 
15
17
  def match?
@@ -21,11 +23,21 @@ module DuckTyper
21
23
 
22
24
  <<~MSG
23
25
  Expected #{@left} and #{@right} to implement compatible \
24
- interfaces, but the following method signatures differ:
26
+ #{interface_label}, but the following method signatures differ:#{strict_note}
25
27
 
26
28
  #{@diff_message.call}
27
29
  MSG
28
30
  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
29
41
  end
30
42
  end
31
43
  end
@@ -7,10 +7,11 @@ require_relative "params_normalizer"
7
7
  module DuckTyper
8
8
  # Compares the public method signatures of two classes and reports mismatches.
9
9
  class InterfaceChecker
10
- def initialize(type: :instance_methods, partial_interface_methods: nil, strict: false)
10
+ def initialize(type: :instance_methods, methods: nil, strict: false, name: nil)
11
11
  @type = type
12
- @partial_interface_methods = partial_interface_methods
12
+ @methods = methods
13
13
  @strict = strict
14
+ @name = name
14
15
  @inspectors = Hash.new { |h, k| h[k] = MethodInspector.for(k, @type) }
15
16
  end
16
17
 
@@ -19,7 +20,7 @@ module DuckTyper
19
20
  match = -> { diff.empty? }
20
21
  diff_message = -> { diff_message(left, right, diff) }
21
22
 
22
- Result.new(left:, right:, match:, diff_message:)
23
+ Result.new(left:, right:, match:, diff_message:, name: @name, strict: @strict)
23
24
  end
24
25
 
25
26
  private
@@ -33,9 +34,9 @@ module DuckTyper
33
34
  end
34
35
 
35
36
  def params_for_comparison(object, params_normalizer)
36
- methods = @partial_interface_methods || @inspectors[object].public_methods
37
+ method_names = @methods || @inspectors[object].public_methods
37
38
 
38
- methods.map do |method_name|
39
+ method_names.map do |method_name|
39
40
  params = method_params(method_name, object)
40
41
  args = params_normalizer.call(params).map do |type, name|
41
42
  case type
@@ -55,6 +56,10 @@ module DuckTyper
55
56
  end
56
57
 
57
58
  def method_params(method_name, object)
59
+ if @inspectors[object].private_method?(method_name)
60
+ raise PrivateMethodError, "private method `#{method_name}' for #{object}"
61
+ end
62
+
58
63
  @inspectors[object].parameters_for(method_name)
59
64
  rescue NameError
60
65
  raise MethodNotFoundError, "undefined method `#{method_name}' for #{object}"
@@ -25,6 +25,10 @@ module DuckTyper
25
25
  @object.public_methods - Object.methods
26
26
  end
27
27
 
28
+ def private_method?(method_name)
29
+ @object.singleton_class.private_method_defined?(method_name)
30
+ end
31
+
28
32
  def parameters_for(method_name)
29
33
  @object.method(method_name).parameters
30
34
  end
@@ -43,6 +47,10 @@ module DuckTyper
43
47
  @object.public_instance_methods - Object.methods
44
48
  end
45
49
 
50
+ def private_method?(method_name)
51
+ @object.private_method_defined?(method_name)
52
+ end
53
+
46
54
  def parameters_for(method_name)
47
55
  @object.instance_method(method_name).parameters
48
56
  end
@@ -4,9 +4,9 @@ require_relative "../duck_typer"
4
4
 
5
5
  module DuckTyper
6
6
  module Minitest
7
- def assert_interfaces_match(objects, type: :instance_methods, methods: nil, strict: false)
7
+ def assert_interfaces_match(objects = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
8
8
  checker = BulkInterfaceChecker
9
- .new(objects, type:, partial_interface_methods: methods, strict:)
9
+ .new(objects, namespace:, type:, methods:, strict:, name:)
10
10
 
11
11
  checker.call do |result|
12
12
  assert result.match?, result.failure_message
@@ -2,10 +2,13 @@
2
2
 
3
3
  require_relative "../duck_typer"
4
4
 
5
- RSpec::Matchers.define :have_matching_interfaces do |type: :instance_methods, methods: nil, strict: false|
6
- match do |objects|
5
+ RSpec::Matchers.define :have_matching_interfaces do |name: nil, type: :instance_methods, methods: nil, strict: false|
6
+ match do |actual|
7
+ namespace = actual.is_a?(Hash) ? actual[:namespace] : nil
8
+ objects = namespace ? nil : actual
9
+
7
10
  checker = DuckTyper::BulkInterfaceChecker
8
- .new(objects, type:, partial_interface_methods: methods, strict:)
11
+ .new(objects, namespace:, type:, methods:, strict:, name:)
9
12
 
10
13
  @failures = checker.call.reject(&:match?)
11
14
  @failures.empty?
@@ -21,15 +24,16 @@ RSpec::Matchers.alias_matcher :have_matching_duck_types, :have_matching_interfac
21
24
  module DuckTyper
22
25
  module RSpec
23
26
  def self.define_shared_example(name = "an interface")
24
- ::RSpec.shared_examples name do |*objects, type: :instance_methods, methods: nil, strict: false|
25
- objects = objects.first
27
+ ::RSpec.shared_examples name do |*args, namespace: nil, name: nil, type: :instance_methods, methods: nil, strict: false|
28
+ objects = namespace ? nil : args.first
29
+
26
30
  # We intentionally avoid reusing the have_matching_interfaces matcher
27
31
  # here. Since this shared example is defined in gem code, RSpec filters
28
32
  # it from its backtrace, causing the Failure/Error: line to show an
29
33
  # internal RSpec constant instead of useful context.
30
34
  it "has compatible interfaces" do
31
35
  checker = DuckTyper::BulkInterfaceChecker
32
- .new(objects, type:, partial_interface_methods: methods, strict:)
36
+ .new(objects, namespace:, type:, methods:, strict:, name:)
33
37
 
34
38
  failures = checker.call.reject(&:match?)
35
39
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DuckTyper
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/duck_typer.rb CHANGED
@@ -8,5 +8,6 @@ require_relative "duck_typer/bulk_interface_checker"
8
8
  # method signatures of classes, surfacing mismatches through your test suite.
9
9
  module DuckTyper
10
10
  class MethodNotFoundError < StandardError; end
11
+ class PrivateMethodError < StandardError; end
11
12
  class TooManyParametersError < StandardError; end
12
13
  end
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.4.0
4
+ version: 0.5.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-09 00:00:00.000000000 Z
11
+ date: 2026-03-13 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email: