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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve
```ruby
style.bold.red["%s"] % "boom" # => "\e[1;31mboom\e[0m"
```
- `kernel:` for `Dry::CLI#call` and `Dry::CLI::Command.new`, and `Dry::CLI::Launcher`, for running a CLI in the same process as its tests. (@kigster)

The CLI and its commands now exit through the kernel they are given, which defaults to `Kernel`. A test can pass an object that records the exit status instead of ending the process. Inside a command, `exit` goes to `#kernel`, so a command's own `exit(1)` is recorded too.

`Dry::CLI::Launcher` takes the arguments, streams and kernel in the order Aruba's in-process launcher passes them:

```ruby
# lib/my_app/launcher.rb
MyApp::Launcher = Dry::CLI::Launcher[MyApp::Commands]

# exe/my_app
MyApp::Launcher.new(ARGV).execute!

# spec/spec_helper.rb
Aruba.configure do |config|
config.command_launcher = :in_process
config.main_class = MyApp::Launcher
- Enforce `required: true` on options. (@capripot and @timriley in #122)

Before, `required: true` was accepted on options but did nothing. Now, if a required option is not given, the command does not run, and prints an error that names the missing option. Required options are marked as REQUIRED in the help output. A required option with a `default:` always has a value, so it is not enforced and not marked as required.
Expand Down
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ gem "dry-types", require: false
unless ENV["CI"]
gem "yard", require: false
end

# Runs a CLI in-process through Dry::CLI::Launcher in spec/integration/launcher_spec.rb
gem "aruba", require: false
54 changes: 47 additions & 7 deletions lib/dry/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ class CLI
require "dry/cli/banner"
require "dry/cli/inflector"
require "dry/cli/dispatch"
require "dry/cli/launcher"

# Stops the CLI with an exit status.
#
# Raised where the CLI has printed help or an error, and rescued by {#call}, which hands the
# status to the kernel. Stopping by raising means that a kernel whose `exit` returns, such as
# the one Aruba uses to run a CLI in-process, still stops the CLI at that point.
#
# @api private
class Halt < StandardError
# @return [Integer] the exit status
attr_reader :status

# @param status [Integer] the exit status
def initialize(status)
super("exit #{status}")
@status = status
end
end
private_constant :Halt

autoload :Spinner, "dry/cli/spinner"

Expand Down Expand Up @@ -93,11 +113,17 @@ def initialize(command_or_registry = nil, &block)
# @param stderr [IO] the error output (defaults to `$stderr`)
# @param stdin [IO] the standard input (defaults to `$stdin`)
# @param stdout [IO] the standard output (defaults to `$stdout`)
# @param kernel [#exit] what the CLI and its commands exit through (defaults to `Kernel`).
# Only an exit is ever sent to it, so a test can pass an object that records the status
# instead of ending the process. See {Dry::CLI::Launcher}.
#
# @since 0.1.0
def call(arguments: ARGV, stderr: $stderr, stdin: $stdin, stdout: $stdout)
def call(arguments: ARGV, stderr: $stderr, stdin: $stdin, stdout: $stdout, kernel: Kernel)
@stderr, @stdin, @stdout = Stream.for(stderr), stdin, Stream.for(stdout)
@kernel = kernel
kommand ? perform_command(arguments) : perform_registry(arguments)
rescue Halt => exception
kernel.exit(exception.status)
rescue SignalException => exception
signal_exception(exception)
rescue Errno::EPIPE
Expand All @@ -123,6 +149,9 @@ def call(arguments: ARGV, stderr: $stderr, stdin: $stdin, stdout: $stdout)
# @api private
attr_reader :stdout

# @api private
attr_reader :kernel

# Invoke the CLI if singular command passed
#
# @param arguments [Array<string>] the command line arguments
Expand Down Expand Up @@ -184,10 +213,10 @@ def build_command(command)
unless command.is_a?(Class)
return command unless command.is_a?(Command)

return command.with_streams(stderr:, stdin:, stdout:)
return command.with_streams(stderr:, stdin:, stdout:, kernel:)
end

return command.new(stderr:, stdin:, stdout:) if CLI.command?(command)
return command.new(stderr:, stdin:, stdout:, kernel:) if CLI.command?(command)

command.new
end
Expand All @@ -196,22 +225,33 @@ def build_command(command)
# @api private
def help(command, prog_name, long: false)
stdout.puts Banner.call(command, prog_name, long: long)
exit(0) # Successful exit
halt(0)
end

# @since 0.6.0
# @api private
def error(result)
stderr.puts(result.error)
exit(1)
halt(1)
end

# @since 1.1.1
def spell_checker(result, arguments)
spell_checker = SpellChecker.call(result, arguments)
stderr.puts "#{spell_checker}\n\n" if spell_checker
stderr.puts Usage.call(result)
exit(1)
halt(1)
end

# Stops the CLI, which {#call} turns into an exit through the kernel.
#
# @param status [Integer] the exit status
#
# @raise [Halt] always
#
# @api private
def halt(status)
raise Halt, status
end

# Handles Exit codes for signals
Expand All @@ -220,7 +260,7 @@ def spell_checker(result, arguments)
# @since 0.7.0
# @api private
def signal_exception(exception)
exit(128 + exception.signo)
kernel.exit(128 + exception.signo)
end

# Check if command
Expand Down
56 changes: 50 additions & 6 deletions lib/dry/cli/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -576,8 +576,8 @@ def self.new(*args, **kwargs, &block)
# @see .auto_initialize_keywords
#
# @api private
private def auto_initialize(stderr: nil, stdin: nil, stdout: nil)
set_streams(stderr:, stdin:, stdout:)
private def auto_initialize(stderr: nil, stdin: nil, stdout: nil, kernel: nil)
set_streams(stderr:, stdin:, stdout:, kernel:)
end

extend Forwardable
Expand Down Expand Up @@ -660,25 +660,47 @@ def stdout
@stdout_stream ||= Stream.for(@stdout)
end

# Returns a copy of this command, configured to write to the given streams.
# What this command exits through
#
# The CLI gives every command the kernel it was called with. Unless it was given a different
# one, this is Ruby's own `Kernel`, so {#exit} behaves as `Kernel#exit` does.
#
# @return [#exit] the kernel given to this command, or `Kernel`
#
# @example
# class MyCommand
# def call
# kernel.exit(2) unless File.exist?("Gemfile")
# end
# end
#
# @api public
# @since x.y.z
def kernel
@kernel || Kernel
end

# Returns a copy of this command, configured to write to the given streams, and to exit
# through the given kernel.
#
# Called on a command registered as an instance, since it is constructed before the CLI is
# invoked, and therefore before it knows where its output should go.
#
# @api private
def with_streams(stderr:, stdin:, stdout:)
dup.send(:set_streams, stderr:, stdin:, stdout:)
def with_streams(stderr:, stdin:, stdout:, kernel: nil)
dup.send(:set_streams, stderr:, stdin:, stdout:, kernel:)
end

private

# @see #with_streams
#
# @api private
def set_streams(stderr:, stdin:, stdout:)
def set_streams(stderr:, stdin:, stdout:, kernel: nil)
@stderr = stderr
@stdin = stdin
@stdout = stdout
@kernel = kernel
@stderr_stream = nil
@stdout_stream = nil

Expand Down Expand Up @@ -711,6 +733,28 @@ def puts(*args)
def print(*args)
stdout.print(*args)
end

# Exits through the command's own {#kernel}, rather than `Kernel` itself.
#
# With the default kernel this is `Kernel#exit`. A kernel given for testing may record the
# status and return instead of ending the process, so return after calling this when the
# rest of `#call` must not run.
#
# @param status [Boolean, Integer] the exit status, as for `Kernel#exit`
#
# @example
# class MyCommand
# def call
# return exit(1) unless File.exist?("Gemfile")
#
# puts "Found a Gemfile"
# end
# end
#
# @since x.y.z
def exit(status = true)
kernel.exit(status)
end
end
end
end
Loading