Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve
### Added

- Support for command namespaces (@gustavothecoder in #135)
- New `:cast` option for options and arguments, allowing to leverage Dry Types or simple procs/lambdas to cast values from string to some other type of value (@katafrakt in #157)

### Changed

Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ eval_gemfile "Gemfile.devtools"
gemspec

gem "backports", "~> 3.15.0", require: false
gem "dry-types", require: false

unless ENV["CI"]
gem "yard", require: false
Expand Down
17 changes: 17 additions & 0 deletions lib/dry/cli/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ class Error < StandardError
class ValueError < Error
end

# @since NEXT
class CastError < Error
def initialize(arg_name:, original_exception: nil)
super
@arg_name = arg_name
@original_exception = original_exception
end

def message
msg = "ERROR when casting #{@arg_name}"
if @original_exception
msg += ": #{@original_exception.message}."
end
msg
end
end

# @since 0.2.1
class UnknownCommandError < Error
# @since 0.2.1
Expand Down
24 changes: 24 additions & 0 deletions lib/dry/cli/option.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ def type
options[:type]
end

# @since NEXT
# @api private
def cast_callable
options[:cast]
end

# @since 0.1.0
# @api private
def values
Expand Down Expand Up @@ -133,6 +139,24 @@ def valid_value?(value)
available_values.map(&:to_s).include?(value.to_s)
end
end

def cast(value)
return value unless cast_callable.respond_to?(:call)

if type == :array
value.map { |el| cast_single(el) }
else
cast_single(value)
end
end

private

def cast_single(value)
cast_callable.call(value)
rescue StandardError => exception
raise CastError.new(arg_name: name, original_exception: exception)
end
end

# Command line argument
Expand Down
10 changes: 6 additions & 4 deletions lib/dry/cli/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def self.call(command, arguments, prog_name)
OptionParser.new do |opts|
command.options.each do |option|
opts.on(*option.parser_options) do |value|
parsed_options[option.name.to_sym] = value
parsed_options[option.name.to_sym] = option.cast(value)
end
end

Expand All @@ -33,6 +33,8 @@ def self.call(command, arguments, prog_name)
parse_required_params(command, arguments, prog_name, parsed_options)
rescue ::OptionParser::ParseError, ValueError
Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"")
rescue CastError => exception
Result.failure(exception.message)
end

# @since 0.1.0
Expand Down Expand Up @@ -81,10 +83,10 @@ def self.match_arguments(command_arguments, arguments, default_values)
result[cmd_arg.name] = arg
break
else
arg = arguments.at(index) || default_values[cmd_arg.name]
raise ValueError unless cmd_arg.valid_value?(arg)
value = arguments.at(index) || default_values[cmd_arg.name]
raise ValueError unless cmd_arg.valid_value?(value)

result[cmd_arg.name] = arg
result[cmd_arg.name] = cmd_arg.cast(value)
end
end

Expand Down
57 changes: 57 additions & 0 deletions spec/integration/casting_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require "open3"
require "json"

RSpec.describe "arguments casting" do
context "Dry Types support" do
it "works with default values" do
output, _, = Open3.capture3("with_casting info system")

expect(JSON.parse(output.chomp)).to eq(
{"lines" => 10, "sudo_mode" => false, "type" => "system"}
)
end

it "casts values according to types" do
output, _, = Open3.capture3("with_casting info system 15 --sudo_mode true")

expect(JSON.parse(output.chomp)).to eq(
{"args" => ["15"], "lines" => 15, "sudo_mode" => true, "type" => "system"}
)
end

it "uses default from :default option over casting default" do
output, _, = Open3.capture3("with_casting info system")

expect(JSON.parse(output.chomp)).to eq(
{"lines" => 10, "sudo_mode" => false, "type" => "system"}
)
end

it "prints a detailed error when casting fails" do
_, stderr, = Open3.capture3("with_casting info system --sudo_mode not_false")
expect(stderr).to eq("ERROR when casting sudo_mode: not_false cannot be coerced to false.\n")
end
end

context "simple lambda casting" do
it "casts value with a simple lambda" do
output, _ = Open3.capture3("with_casting info system --uid 16")

expect(JSON.parse(output.chomp)).to eq(
{"lines" => 10, "sudo_mode" => false, "type" => "system", "uid" => 16}
)
end
end

context "compatibility with :type" do
it "with :array type casts every element" do
output, _ = Open3.capture3("with_casting info system --flags foo,baz,hoozah")

expect(JSON.parse(output.chomp)).to eq(
{"lines" => 10, "sudo_mode" => false, "type" => "system", "flags" => ["oof", "zab", "hazooh"]}
)
end
end
end
32 changes: 32 additions & 0 deletions spec/support/fixtures/with_casting
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

$LOAD_PATH.unshift File.expand_path("#{__dir__}/../../../lib")
require "dry/cli"
require_relative "../../../lib/dry/cli/command"
require "json"
require "dry/types"

Types = Dry.Types()

module WithDryTypes
extend Dry::CLI::Registry

class Info < Dry::CLI::Command
desc "Display info"

argument :type, desc: "Type of the information", required: true
argument :lines, desc: "Number of lines to show", cast: Types::Coercible::Integer.default(5), default: 10
option :sudo_mode, desc: "Enable sudo mode?", cast: Types::Params::Bool, default: false
option :uid, desc: "UID of the user to run as", cast: ->(v) { v.to_i }
option :flags, cast: ->(f) { f.reverse }, type: :array

def call(**options)
puts JSON.dump(options)
end
end

register "info", Info
end

Dry.CLI(WithDryTypes).call