diff --git a/CHANGELOG.md b/CHANGELOG.md index 558fe5c..bae04a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Gemfile b/Gemfile index 116f528..cf470f6 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/lib/dry/cli/errors.rb b/lib/dry/cli/errors.rb index 9833f0a..358a8ca 100644 --- a/lib/dry/cli/errors.rb +++ b/lib/dry/cli/errors.rb @@ -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 diff --git a/lib/dry/cli/option.rb b/lib/dry/cli/option.rb index aa72dd4..66e9e07 100644 --- a/lib/dry/cli/option.rb +++ b/lib/dry/cli/option.rb @@ -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 @@ -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 diff --git a/lib/dry/cli/parser.rb b/lib/dry/cli/parser.rb index 0b63ea5..eeed73a 100644 --- a/lib/dry/cli/parser.rb +++ b/lib/dry/cli/parser.rb @@ -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 @@ -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 @@ -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 diff --git a/spec/integration/casting_spec.rb b/spec/integration/casting_spec.rb new file mode 100644 index 0000000..290683b --- /dev/null +++ b/spec/integration/casting_spec.rb @@ -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 diff --git a/spec/support/fixtures/with_casting b/spec/support/fixtures/with_casting new file mode 100755 index 0000000..2ddb532 --- /dev/null +++ b/spec/support/fixtures/with_casting @@ -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