Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,13 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve
```

Existing commands that call `Kernel#exit` itself still exit the process directly, and do not use `exit:`. To use `exit:`, change these calls to `throw :exit, code`, or raise `Dry::CLI::ExitError`.
- `Dry::CLI.accepted_kwargs`, which returns only the keyword args that a method or proc accepts, based on its params. Use it when you wrap a command's `#call`, to pass on only the keyword args that the next `#call` accepts. (@timriley)

### Changed

- Commands and callbacks are now passed only the params their `#call` actually declares, so they no longer need a `**` catch-all to tolerate params contributed by other gems. (@afomera in #165)
- Commands and callbacks are now passed only the keyword args that match the params their `#call` declares, so they no longer need a `**` catch-all param to tolerate options contributed by other gems. (@afomera in #165)

Params are passed through in full when `#call` can take them as a whole: when it declares a keyword splat or a positional. Otherwise they're matched against the keywords it declares.
Every keyword arg is passed when `#call` can take them as a whole: when it declares a keyword splat param or a positional param. Otherwise they're matched against the keyword params it declares.
- `Dry::CLI::Command.new` now takes three keyword arguments: `stdout:`, `stdin:` and `stderr:`. These can be used to inject I/O streams, which is useful for testing. (@aaronmallen in #151, @timriley in #167)

These arguments are taken by `.new` and set on the command instance before `#initialize` runs, so `#initialize` in a subclass only needs to worry about its own arguments, with no call to `super` required:
Expand Down
38 changes: 36 additions & 2 deletions lib/dry/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,40 @@ def self.inherits?(obj, klass)
end
end

# Returns the keyword args from `kwargs` that `callable` can accept, based on the params it
# declares.
#
# A callable declaring a keyword splat param (`**`) or a positional param gets every keyword
# arg. Any other callable gets only the keyword args that match its keyword params.
#
# Dry CLI uses this to call each command's `#call` and callbacks with only the keyword args that
# they accept. Use this yourself if you wrap a command's `#call`, to ensure that you only pass
# on the keyword args that the next `#call` accepts.
#
# @example
# class Deploy < Dry::CLI::Command
# def call(target:, force: false) = ...
# end
#
# kwargs = {target: "production", env: "test"}
#
# Dry::CLI.accepted_kwargs(Deploy.new.method(:call), kwargs)
# # => {target: "production"}
#
# Dry::CLI.accepted_kwargs(proc { |**opts| opts }, kwargs)
# # => {target: "production", env: "test"}
#
# @param callable [Method, Proc] the method or proc to be called
# @param kwargs [Hash] the keyword args to choose from
#
# @return [Hash] the keyword args that `callable` can accept
#
# @since 2.0.0
# @api public
def self.accepted_kwargs(callable, kwargs)
Dispatch.accepted_kwargs(callable, kwargs)
end

# Create a new instance
#
# @param command_or_registry [Dry::CLI::Registry, Dry::CLI::Command]
Expand Down Expand Up @@ -176,7 +210,7 @@ def call(
# @api private
def perform_command(arguments)
command, args = parse(kommand, arguments, [])
command.call(**Dispatch.command_args_for(command, args))
command.call(**Dispatch.command_accepted_kwargs(command, args))
end

# Invoke the CLI if registry passed
Expand All @@ -194,7 +228,7 @@ def perform_registry(arguments)
return stderr.puts(Usage.call(result)) unless command.respond_to?(:call)

result.before_callbacks.run(command, **args)
command.call(**Dispatch.command_args_for(command, args))
command.call(**Dispatch.command_accepted_kwargs(command, args))
result.after_callbacks.run(command, **args)
end

Expand Down
2 changes: 1 addition & 1 deletion lib/dry/cli/command_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ def append(&callback)
# @api private
def run(context, **args)
chain.each do |callback|
context.instance_exec(**Dispatch.args_for(callback, args), &callback)
context.instance_exec(**Dispatch.accepted_kwargs(callback, args), &callback)
end
end
end
Expand Down
49 changes: 26 additions & 23 deletions lib/dry/cli/dispatch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,50 +2,53 @@

module Dry
class CLI
# Matches parsed params against what a command or callback can actually accept.
# Matches the parsed keyword args against the params that a command or callback declares.
#
# Commands and callbacks only declare the params they care about, but the parsed params also
# carry whatever other gems have contributed to the same command (see {Registry#option}). This
# keeps each of them free of a catch-all `**` just to tolerate params they know nothing about.
# Commands and callbacks only declare params for the keyword args they care about, but the
# parsed keyword args also carry whatever other gems have contributed to the same command (see
# {Registry#option}). This keeps each of them free of a catch-all `**` param just to tolerate
# keyword args they know nothing about.
#
# For public use, see {Dry::CLI.accepted_kwargs}.
#
# @api private
module Dispatch
# Parameter kinds that mean the callable can be given every param. See {.args_for}.
# Param kinds that mean the callable can be given every keyword arg. See {.accepted_kwargs}.
WHOLE_HASH = %i[keyrest req opt rest].freeze

# The params from `args` that `callable` can accept.
# The keyword args from `kwargs` that `callable` can accept.
#
# Two kinds of callable are given every param: one declaring a keyword splat, which absorbs
# whatever it doesn't name; and one declaring a positional, because `**` collapses to a
# single Hash when the callable declares no keywords.
# Two kinds of callable are given every keyword arg: one declaring a keyword splat param,
# which absorbs whatever it doesn't name; and one declaring a positional param, because `**`
# collapses to a single Hash when the callable declares no keyword params.
#
# Every other callable is given only the keywords it declares. This also means that a callable
# declaring no params (e.g. `def call`) will receive none.
# Every other callable is given only the keyword args that match its keyword params. This also
# means that a callable declaring no params (e.g. `def call`) will receive no args.
#
# @param callable [Method, Proc] the command's `#call` method, or a callback
# @param args [Hash] the parsed params
# @param kwargs [Hash] the parsed keyword args
#
# @return [Hash]
def self.args_for(callable, args)
parameters = callable.parameters
return args if parameters.any? { |type, _| WHOLE_HASH.include?(type) }
def self.accepted_kwargs(callable, kwargs)
params = callable.parameters
return kwargs if params.any? { |type, _| WHOLE_HASH.include?(type) }

args.slice(*parameters.filter_map { |type, name| name if type == :key || type == :keyreq })
kwargs.slice(*params.filter_map { |type, name| name if type == :key || type == :keyreq })
end

# The params that `command` can accept, for commands implementing `#call`.
# The keyword args that `command` can accept, for commands implementing `#call`.
#
# Falls back to all of them for commands that answer `#call` via `method_missing`, and so
# have no signature to inspect.
# Falls back to all of them for commands that answer `#call` via `method_missing`, and so have
# no params to inspect.
#
# @param command [Dry::CLI::Command] the command instance
# @param args [Hash] the parsed params
# @param kwargs [Hash] the parsed keyword args
#
# @return [Hash]
def self.command_args_for(command, args)
args_for(command.method(:call), args)
def self.command_accepted_kwargs(command, kwargs)
accepted_kwargs(command.method(:call), kwargs)
rescue NameError
args
kwargs
end
end
end
Expand Down
17 changes: 17 additions & 0 deletions spec/unit/dry/cli/accepted_kwargs_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

RSpec.describe "Dry::CLI.accepted_kwargs" do
let(:kwargs) { {name: "web", verbose: true} }

it "returns the keyword args the callable can accept" do
method = Class.new { def call(name:) = name }.new.method(:call)

expect(Dry::CLI.accepted_kwargs(method, kwargs)).to eq(name: "web")
end

it "returns every keyword arg for a callable declaring a keyword splat" do
block = proc { |**opts| opts }

expect(Dry::CLI.accepted_kwargs(block, kwargs)).to eq(kwargs)
end
end
42 changes: 21 additions & 21 deletions spec/unit/dry/cli/dispatch_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ def callable(&definition)
Class.new { define_method(:call, &definition) }.new.method(:call)
end

describe ".args_for" do
describe ".accepted_kwargs" do
context "when the callable declares a keyword splat" do
it "passes everything through for `**opts`" do
method = callable { |**opts| opts }

expect(described_class.args_for(method, args)).to eq(args)
expect(described_class.accepted_kwargs(method, args)).to eq(args)
end

it "passes everything through for `name:, **`" do
method = callable { |name:, **| name }

expect(described_class.args_for(method, args)).to eq(args)
expect(described_class.accepted_kwargs(method, args)).to eq(args)
end
end

Expand All @@ -28,89 +28,89 @@ def callable(&definition)
it "passes everything through for `*args`" do
method = callable { |*positional| positional }

expect(described_class.args_for(method, args)).to eq(args)
expect(described_class.accepted_kwargs(method, args)).to eq(args)
end

it "passes everything through for one required positional" do
method = callable { |params| params }
method = callable { |hash| hash }

expect(described_class.args_for(method, args)).to eq(args)
expect(described_class.accepted_kwargs(method, args)).to eq(args)
end

it "passes everything through for a block taking one positional" do
block = proc { |params| params }
block = proc { |hash| hash }

expect(described_class.args_for(block, args)).to eq(args)
expect(described_class.accepted_kwargs(block, args)).to eq(args)
end
end

context "when the callable declares nothing at all" do
it "passes nothing to a method taking no params" do
method = callable { nil }

expect(described_class.args_for(method, args)).to eq({})
expect(described_class.accepted_kwargs(method, args)).to eq({})
end

it "passes nothing to a block taking nothing" do
block = proc { nil }

expect(described_class.args_for(block, args)).to eq({})
expect(described_class.accepted_kwargs(block, args)).to eq({})
end
end

context "when the callable declares keywords and no splat" do
it "passes only the declared keywords" do
method = callable { |name:, skip_tests: false| [name, skip_tests] }

expect(described_class.args_for(method, args)).to eq(name: "web", skip_tests: true)
expect(described_class.accepted_kwargs(method, args)).to eq(name: "web", skip_tests: true)
end

it "omits keywords that weren't parsed" do
method = callable { |name:, missing: nil| [name, missing] }

expect(described_class.args_for(method, args)).to eq(name: "web")
expect(described_class.accepted_kwargs(method, args)).to eq(name: "web")
end

it "leaves a required keyword missing, so the call still raises" do
method = callable { |absent:| absent }

expect(described_class.args_for(method, args)).to eq({})
expect(described_class.accepted_kwargs(method, args)).to eq({})
# JRuby renders the keyword without the leading colon
expect { method.call(**described_class.args_for(method, args)) }
expect { method.call(**described_class.accepted_kwargs(method, args)) }
.to raise_error(ArgumentError, /missing keyword: :?absent/)
end
end

it "lets a narrow signature accept params it doesn't declare" do
it "lets a narrow signature accept args it doesn't declare" do
method = callable { |name:| name }

expect { method.call(**args) }.to raise_error(ArgumentError, /unknown keyword/)
expect { method.call(**described_class.args_for(method, args)) }.not_to raise_error
expect { method.call(**described_class.accepted_kwargs(method, args)) }.not_to raise_error
end

it "lets a signature declaring no params be called at all" do
method = callable { nil }

expect { method.call(**args) }.to raise_error(ArgumentError, /wrong number of arguments/)
expect { method.call(**described_class.args_for(method, args)) }.not_to raise_error
expect { method.call(**described_class.accepted_kwargs(method, args)) }.not_to raise_error
end
end

describe ".command_args_for" do
describe ".command_accepted_kwargs" do
it "filters against the command's #call" do
command = Class.new(Dry::CLI::Command) do
def call(name:); end
end.new

expect(described_class.command_args_for(command, args)).to eq(name: "web")
expect(described_class.command_accepted_kwargs(command, args)).to eq(name: "web")
end

it "passes nothing to a command declaring no params" do
command = Class.new(Dry::CLI::Command) do
def call; end
end.new

expect(described_class.command_args_for(command, args)).to eq({})
expect(described_class.command_accepted_kwargs(command, args)).to eq({})
end

it "passes everything through when #call has no inspectable signature" do
Expand All @@ -124,7 +124,7 @@ def method_missing(name, *rest)
end
end.new

expect(described_class.command_args_for(command, args)).to eq(args)
expect(described_class.command_accepted_kwargs(command, args)).to eq(args)
end
end
end
Loading