From 675c2816dd412d5e3c318462f25e43b502e22a54 Mon Sep 17 00:00:00 2001 From: Tim Riley Date: Fri, 30 Oct 2026 09:50:02 +1100 Subject: [PATCH] Add `Dry::CLI.accepted_kwargs` public method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This makes public the API that we’re using in Hanami CLI to wrap command `#call` methods and ensure we pass along only their accepted kwargs. As part of this, be more precise when we talk about “args” (or “kwargs”) vs “params”. Args are the explicit values we pass to a method; params are the declarad parameters on the method itself, nothing more. --- CHANGELOG.md | 5 ++- lib/dry/cli.rb | 38 +++++++++++++++++- lib/dry/cli/command_registry.rb | 2 +- lib/dry/cli/dispatch.rb | 49 ++++++++++++----------- spec/unit/dry/cli/accepted_kwargs_spec.rb | 17 ++++++++ spec/unit/dry/cli/dispatch_spec.rb | 42 +++++++++---------- 6 files changed, 104 insertions(+), 49 deletions(-) create mode 100644 spec/unit/dry/cli/accepted_kwargs_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b56706..1ff41a50 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 f664b609..7e33723d 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 6a96c319..60ff03a3 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 2fa7831a..c0f19743 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 00000000..f05acfb6 --- /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 8378387e..4ec85ad2 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