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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +52 -15
- data/lib/duck_typer/bulk_interface_checker.rb +22 -4
- data/lib/duck_typer/interface_checker/result.rb +14 -2
- data/lib/duck_typer/interface_checker.rb +10 -5
- data/lib/duck_typer/method_inspector.rb +8 -0
- data/lib/duck_typer/minitest.rb +2 -2
- data/lib/duck_typer/rspec.rb +10 -6
- data/lib/duck_typer/version.rb +1 -1
- data/lib/duck_typer.rb +1 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f51efa5736485f62f43d321e7e3d8d612081c6b5eca59bb66823d78cb38b5442
|
|
4
|
+
data.tar.gz: b75e8517ce41187b4df7b9f5260aa8da119feec892f34bc79e6e2865bd90bce8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
1
|
+
# Duck Typer
|
|
2
2
|
|
|
3
3
|
[](https://github.com/thoughtbot/duck_typer/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
5
|
<div align="center">
|
|
6
|
-
<img alt="
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
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.
|
|
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,
|
|
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,
|
|
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 `
|
|
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,
|
|
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.**
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
7
|
-
raise ArgumentError, "
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
10
|
+
def initialize(type: :instance_methods, methods: nil, strict: false, name: nil)
|
|
11
11
|
@type = type
|
|
12
|
-
@
|
|
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
|
-
|
|
37
|
+
method_names = @methods || @inspectors[object].public_methods
|
|
37
38
|
|
|
38
|
-
|
|
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
|
data/lib/duck_typer/minitest.rb
CHANGED
|
@@ -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:,
|
|
9
|
+
.new(objects, namespace:, type:, methods:, strict:, name:)
|
|
10
10
|
|
|
11
11
|
checker.call do |result|
|
|
12
12
|
assert result.match?, result.failure_message
|
data/lib/duck_typer/rspec.rb
CHANGED
|
@@ -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 |
|
|
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:,
|
|
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 |*
|
|
25
|
-
objects =
|
|
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:,
|
|
36
|
+
.new(objects, namespace:, type:, methods:, strict:, name:)
|
|
33
37
|
|
|
34
38
|
failures = checker.call.reject(&:match?)
|
|
35
39
|
|
data/lib/duck_typer/version.rb
CHANGED
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
|
+
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-
|
|
11
|
+
date: 2026-03-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description:
|
|
14
14
|
email:
|