duck_typer 0.4.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 +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +178 -15
- data/lib/duck_typer/bulk_interface_checker.rb +7 -7
- data/lib/duck_typer/canonical_interface_checker/result.rb +36 -0
- data/lib/duck_typer/canonical_interface_checker.rb +20 -0
- data/lib/duck_typer/interface_checker/result.rb +13 -3
- data/lib/duck_typer/interface_checker.rb +10 -5
- data/lib/duck_typer/interface_setup.rb +35 -0
- data/lib/duck_typer/method_inspector.rb +8 -0
- data/lib/duck_typer/minitest.rb +10 -2
- data/lib/duck_typer/result_formatting.rb +15 -0
- data/lib/duck_typer/rspec.rb +27 -6
- data/lib/duck_typer/version.rb +1 -1
- data/lib/duck_typer.rb +2 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7c4910b3357c23523997ed6cbb76cc1add4824af460fe8845390f1f69cfe2e6
|
|
4
|
+
data.tar.gz: 4aea61000506666b3ce649e08398247c9ab72c75826cc9e4819e91471547f8dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 48bcd0be970572a490e373064d3894bd174aaffc29b52a0c7b8beea030530b0661bde8221b01a0b681d3511478259cb7edfe858383def4a1e8f22f1afec36577
|
|
7
|
+
data.tar.gz: 923d8722ae7ac8fb7f86aa12292056014fb00f5328c2034d63ed8b3c87984a0cce8890a0983d48fb2148b7d9ff1eb68888079cbaac90d4a7a7ebf33c8b5d6156
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
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
|
+
|
|
20
|
+
## [0.5.0] - 2026-03-13
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- `name:` option: includes the interface name in failure messages
|
|
24
|
+
(e.g. `compatible "Linkable" interfaces`)
|
|
25
|
+
- `namespace:` option: resolves a module's constants as the list of
|
|
26
|
+
objects to compare; infers the interface name from the module name
|
|
27
|
+
when `name:` is not given
|
|
28
|
+
- Strict mode now notes itself in the failure message:
|
|
29
|
+
`(strict mode: positional argument names must match)`
|
|
30
|
+
- `BulkInterfaceChecker` and `InterfaceChecker` raise
|
|
31
|
+
`PrivateMethodError` when a private method is specified in
|
|
32
|
+
`methods:`
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- `partial_interface_methods:` renamed to `methods:` across
|
|
36
|
+
`BulkInterfaceChecker`, `InterfaceChecker`, `assert_interfaces_match`,
|
|
37
|
+
and `have_matching_interfaces` (breaking change)
|
|
38
|
+
- RSpec matcher now accepts a module as the subject via
|
|
39
|
+
`expect(namespace: MyModule).to have_matching_interfaces`
|
|
40
|
+
- RSpec shared example now accepts `namespace:` as a keyword argument
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- Numbered block parameters (`_1`) replaced with named ones to avoid
|
|
44
|
+
`Style/ItBlockParameter` lint failures on Ruby 3.4
|
|
45
|
+
|
|
3
46
|
## [0.4.0] - 2026-03-09
|
|
4
47
|
|
|
5
48
|
### 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,95 @@ 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
|
+
|
|
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
|
+
|
|
154
243
|
### RSpec
|
|
155
244
|
|
|
156
245
|
Require the RSpec integration in your `spec_helper.rb`:
|
|
@@ -198,6 +287,73 @@ expect([StripeProcessor, PaypalProcessor])
|
|
|
198
287
|
.to have_matching_interfaces(strict: true)
|
|
199
288
|
```
|
|
200
289
|
|
|
290
|
+
To include the interface name in failure messages, use `name:`:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
expect([StripeProcessor, PaypalProcessor])
|
|
294
|
+
.to have_matching_interfaces(name: "PaymentProcessor")
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
To check all classes in a module, pass it as a named subject:
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
expect(namespace: Payments).to have_matching_interfaces
|
|
301
|
+
```
|
|
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
|
+
|
|
201
357
|
#### Shared example
|
|
202
358
|
|
|
203
359
|
If you prefer shared examples, register one in `spec_helper.rb`
|
|
@@ -226,7 +382,8 @@ RSpec.describe "payment processors" do
|
|
|
226
382
|
end
|
|
227
383
|
```
|
|
228
384
|
|
|
229
|
-
The same `type:`, `methods:`, and `
|
|
385
|
+
The same `type:`, `methods:`, `strict:`, and `name:` options are
|
|
386
|
+
supported:
|
|
230
387
|
|
|
231
388
|
```ruby
|
|
232
389
|
it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
|
|
@@ -235,9 +392,15 @@ it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
|
|
|
235
392
|
strict: true
|
|
236
393
|
```
|
|
237
394
|
|
|
395
|
+
To check all classes in a module, pass it with `namespace:`:
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
it_behaves_like "an interface", namespace: Payments
|
|
399
|
+
```
|
|
400
|
+
|
|
238
401
|
## Limitations
|
|
239
402
|
|
|
240
|
-
By default,
|
|
403
|
+
By default, Duck Typer checks the **structure** of public method
|
|
241
404
|
signatures — the number of parameters, their kinds (required,
|
|
242
405
|
optional, keyword, rest, block), and keyword argument names. In
|
|
243
406
|
strict mode, positional argument names are also compared. It does
|
|
@@ -245,7 +408,7 @@ not
|
|
|
245
408
|
verify the following, which should be covered by your regular
|
|
246
409
|
test suite:
|
|
247
410
|
|
|
248
|
-
- **Parameter types.**
|
|
411
|
+
- **Parameter types.** Duck Typer only checks that both methods
|
|
249
412
|
declare an `amount` parameter — not what type of value it
|
|
250
413
|
expects. Two methods with identical signatures may still be
|
|
251
414
|
incompatible if they expect different types.
|
|
@@ -253,7 +416,7 @@ test suite:
|
|
|
253
416
|
but return completely different things.
|
|
254
417
|
- **Behavior.** Matching signatures are a necessary but not
|
|
255
418
|
sufficient condition for duck typing to work correctly at
|
|
256
|
-
runtime.
|
|
419
|
+
runtime. Duck Typer catches structural drift, not semantic
|
|
257
420
|
divergence.
|
|
258
421
|
|
|
259
422
|
Some things are intentionally out of scope:
|
|
@@ -266,7 +429,7 @@ Some things are intentionally out of scope:
|
|
|
266
429
|
|
|
267
430
|
## Stability
|
|
268
431
|
|
|
269
|
-
|
|
432
|
+
Duck Typer is intentionally minimal. It reflects Ruby's own method
|
|
270
433
|
introspection API, which rarely changes — so the gem rarely needs
|
|
271
434
|
to either. When it does change, it will most likely be for additive reasons:
|
|
272
435
|
new API options, better error messages, or broader test framework
|
|
@@ -295,7 +458,7 @@ Thank you, [contributors]!
|
|
|
295
458
|
|
|
296
459
|
## License
|
|
297
460
|
|
|
298
|
-
|
|
461
|
+
Duck Typer is Copyright (c) thoughtbot, inc.
|
|
299
462
|
It is free software, and may be redistributed
|
|
300
463
|
under the terms specified in the [LICENSE] file.
|
|
301
464
|
|
|
@@ -1,19 +1,19 @@
|
|
|
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
|
-
def initialize(objects, type: :instance_methods,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@objects = objects
|
|
10
|
-
@checker = InterfaceChecker.new(type:, partial_interface_methods:, strict:)
|
|
8
|
+
def initialize(objects = nil, namespace: nil, type: :instance_methods, methods: nil, strict: false, name: nil)
|
|
9
|
+
@setup = InterfaceSetup.new(objects, namespace:, type:, methods:, strict:, name:, minimum: 2)
|
|
11
10
|
end
|
|
12
11
|
|
|
13
12
|
def call(&block)
|
|
14
|
-
@objects.each_cons(2).map do |left, right|
|
|
15
|
-
result = @checker.call(left, right)
|
|
13
|
+
@setup.objects.each_cons(2).map do |left, right|
|
|
14
|
+
result = @setup.checker.call(left, right)
|
|
16
15
|
block&.call(result)
|
|
16
|
+
|
|
17
17
|
result
|
|
18
18
|
end
|
|
19
19
|
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,29 +1,39 @@
|
|
|
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
|
-
def initialize(left:, right:, match:, diff_message:)
|
|
12
|
+
def initialize(left:, right:, match:, diff_message:, name:, strict:)
|
|
9
13
|
@left = left
|
|
10
14
|
@right = right
|
|
11
15
|
@match = match
|
|
12
16
|
@diff_message = diff_message
|
|
17
|
+
@name = name
|
|
18
|
+
@strict = strict
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
def match?
|
|
16
22
|
@match.call
|
|
17
23
|
end
|
|
18
24
|
|
|
25
|
+
def diff_message
|
|
26
|
+
@diff_message.call
|
|
27
|
+
end
|
|
28
|
+
|
|
19
29
|
def failure_message
|
|
20
30
|
return if match?
|
|
21
31
|
|
|
22
32
|
<<~MSG
|
|
23
33
|
Expected #{@left} and #{@right} to implement compatible \
|
|
24
|
-
|
|
34
|
+
#{interface_label}, but the following method signatures differ:#{strict_note}
|
|
25
35
|
|
|
26
|
-
#{
|
|
36
|
+
#{diff_message}
|
|
27
37
|
MSG
|
|
28
38
|
end
|
|
29
39
|
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}"
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
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?
|
|
@@ -18,18 +21,36 @@ end
|
|
|
18
21
|
|
|
19
22
|
RSpec::Matchers.alias_matcher :have_matching_duck_types, :have_matching_interfaces
|
|
20
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
|
+
|
|
21
41
|
module DuckTyper
|
|
22
42
|
module RSpec
|
|
23
43
|
def self.define_shared_example(name = "an interface")
|
|
24
|
-
::RSpec.shared_examples name do |*
|
|
25
|
-
objects =
|
|
44
|
+
::RSpec.shared_examples name do |*args, namespace: nil, name: nil, type: :instance_methods, methods: nil, strict: false|
|
|
45
|
+
objects = namespace ? nil : args.first
|
|
46
|
+
|
|
26
47
|
# We intentionally avoid reusing the have_matching_interfaces matcher
|
|
27
48
|
# here. Since this shared example is defined in gem code, RSpec filters
|
|
28
49
|
# it from its backtrace, causing the Failure/Error: line to show an
|
|
29
50
|
# internal RSpec constant instead of useful context.
|
|
30
51
|
it "has compatible interfaces" do
|
|
31
52
|
checker = DuckTyper::BulkInterfaceChecker
|
|
32
|
-
.new(objects, type:,
|
|
53
|
+
.new(objects, namespace:, type:, methods:, strict:, name:)
|
|
33
54
|
|
|
34
55
|
failures = checker.call.reject(&:match?)
|
|
35
56
|
|
data/lib/duck_typer/version.rb
CHANGED
data/lib/duck_typer.rb
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
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.
|
|
9
10
|
module DuckTyper
|
|
10
11
|
class MethodNotFoundError < StandardError; end
|
|
12
|
+
class PrivateMethodError < StandardError; end
|
|
11
13
|
class TooManyParametersError < StandardError; end
|
|
12
14
|
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.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-
|
|
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
|