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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +126 -0
- data/lib/duck_typer/bulk_interface_checker.rb +5 -23
- 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 +9 -11
- data/lib/duck_typer/interface_setup.rb +35 -0
- data/lib/duck_typer/minitest.rb +8 -0
- data/lib/duck_typer/result_formatting.rb +15 -0
- data/lib/duck_typer/rspec.rb +17 -0
- data/lib/duck_typer/version.rb +1 -1
- data/lib/duck_typer.rb +1 -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,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
|
-
|
|
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
|
-
#{
|
|
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
|
data/lib/duck_typer/minitest.rb
CHANGED
|
@@ -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
|
@@ -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")
|
data/lib/duck_typer/version.rb
CHANGED
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.
|
|
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
|