diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b5670..1ff41a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/lib/dry/cli.rb b/lib/dry/cli.rb index f664b60..7e33723 100644 --- a/lib/dry/cli.rb +++ b/lib/dry/cli.rb @@ -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] @@ -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 @@ -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 diff --git a/lib/dry/cli/command_registry.rb b/lib/dry/cli/command_registry.rb index 6a96c31..60ff03a 100644 --- a/lib/dry/cli/command_registry.rb +++ b/lib/dry/cli/command_registry.rb @@ -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 diff --git a/lib/dry/cli/dispatch.rb b/lib/dry/cli/dispatch.rb index 2fa7831..c0f1974 100644 --- a/lib/dry/cli/dispatch.rb +++ b/lib/dry/cli/dispatch.rb @@ -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 diff --git a/spec/unit/dry/cli/accepted_kwargs_spec.rb b/spec/unit/dry/cli/accepted_kwargs_spec.rb new file mode 100644 index 0000000..f05acfb --- /dev/null +++ b/spec/unit/dry/cli/accepted_kwargs_spec.rb @@ -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 diff --git a/spec/unit/dry/cli/dispatch_spec.rb b/spec/unit/dry/cli/dispatch_spec.rb index 8378387..4ec85ad 100644 --- a/spec/unit/dry/cli/dispatch_spec.rb +++ b/spec/unit/dry/cli/dispatch_spec.rb @@ -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 @@ -28,19 +28,19 @@ 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 @@ -48,13 +48,13 @@ def callable(&definition) 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 @@ -62,47 +62,47 @@ def callable(&definition) 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 @@ -110,7 +110,7 @@ def call(name:); end 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 @@ -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