Read more

Running external commands with Open3

Tobias Kraze
March 09, 2017Software engineer at makandra GmbH

There are various ways to run external commands from within Ruby, but the most powerful ones are Open3.capture3 and Open3.popen3. Since those can do almost everything you would possibly need in a clean way, I prefer to simply always use them.

Illustration online protection

Rails Long Term Support

Rails LTS provides security patches for old versions of Ruby on Rails (2.3, 3.2, 4.2 and 5.2)

  • Prevents you from data breaches and liability risks
  • Upgrade at your own pace
  • Works with modern Rubies
Read more Show archive.org snapshot

Behind the scenes, Open3 actually just uses Ruby's spawn command, but gives you a much better API.

Open3.capture3

Basic usage is

require 'open3'

stdout_str, error_str, status = Open3.capture3('/some/binary', 'with', 'some', 'args')
if status.success?
  # okay
else
  raise "did not work"
end

Open3 will raise an error if the binary cannot run at all, but won't raise an error if the binary returns an exit code > 0. This will instead be reflected in status.

stdout_str, error_str are strings containing the command's output and error output.

Shell expansion

If you have to run the command through the user's shell, pass the command as a string instead of an array:

Open3.capture3('/some/binary with some args')

This is not recommended though, due to additional overhead and the potential for shell injections.

Stdin

If the command reads from stdin and you want to feed it some data, you can use

Open3.capture3('/some/binary', stdin_data: 'this is read from stdin')

ENV variables

To pass additional ENV variables to the command, pass them as a hash in the first argument:

Open3.capture3({'VAR' => 'value'}, '/some/binary')

You can use unsetenv_others: true to clear all other ENV variables.

Working directory

Run the command with a different working directory, by using chdir:

Open3.capture3('/some/binary', chdir: '/some/directory')

File descriptors

Ruby's spawn command gives you full control over file descriptors (and lets you point stdin to a file, or merge stdout and stderr) etc. Open3 however does not allow you to do this. If you need it, you have to use spawn directly.

Open3.capture2

If you are not interested in the error input you can use the capture2 method instead. The parameters are the same as for capture3.

stdout_str, status = Open3.capture2('/some/binary', 'with', 'some', 'args')

Open3.capture2e

Another alternative would be capture2e. This will combine the outputs from stdout and stderr into one.

stdout_and_stderr_str, status = Open3.capture2e('/some/binary', 'with', 'some', 'args')

Open3.popen3

This version allows your code to interact with the external command while it is running. Always use it in its block form:

Open3.popen3('/some/command') do |stdin, stdout, stderr, wait_thr|
  stdin.puts "This is sent to the command"
  stdin.close                # we're done
  stdout_str = stdout.read   # read stdout to string. note that this will block until the command is done!
  stderr_str = stderr.read   # read stderr to string
  status = wait_thr.value    # will block until the command finishes; returns status that responds to .success? etc
end

This form is useful for long running commands you have to interact with, or if you want to pipe large amounts of data without keeping everything in memory.

However, it is tricky to use this correctly in all circumstances. If the command produces lots of output, for example, you need to make sure the stdout and stderr streams are continuously read, otherwise the command will block. You would need to use threads (or IO.select) to achieve this.

Tobias Kraze
March 09, 2017Software engineer at makandra GmbH
Posted by Tobias Kraze to makandra dev (2017-03-09 11:08)