(My note: before the episode):
mv ~/bin/goon ~/tmp/goon
In Episode 068, we discussed the Port module, which is a thin wrapper around Erlang's port module. Since that Episode, Alexei Sholik has released a library called Porcelain which smooths out quite a few of the Port module's rough edges. Let's have a look at it.
Project
We'll start a new project to play with it:
mix new porcelain_playground
cd porcelain_playground
Alright, so we'll add a dependency for it and start it with our app:
def application do
[applications: [:porcelain]]
end
def deps do
[
{:porcelain, "~> 1.0.0"}
]
end
Now that that's in place, let's have a look at writing some tests to interact
with external programs using porcelain. Open up
test/porcelain_playground_test.exs
and we'll reproduce some of Episode 068's
tasks, but with tests this time, and a nicer API.
First, we'll set up some test files to use in our tests:
@test_files_path "test/files/elixir_port"
setup do
File.mkdir_p(@test_files_path)
:ok
end
teardown do
File.rm_rf(@test_files_path)
:ok
end
Now let's write a test that verifies that we can spawn "ls" and read it, and see various options Porcelain gives us for this:
test "evaluating and getting a response" do
# Now, to do this we can use Porcelain.shell, but let's inspect
# the output to see what the response is like
File.mkdir_p(@test_files_path <> "/1")
File.touch(@test_files_path <> "/1/first")
File.touch(@test_files_path <> "/1/second")
IO.inspect Porcelain.shell("ls #{@test_files_path}/1")
end
Run the tests. So here we can see that we get back a
%Porcelain.Result{}
struct which has the attributes
[:error, :out, :status]
. For now we'll just
use dot to access these values out, so let's verify in this test that the value
of
ls
is what we expect:
test "evaluating and getting a response" do
File.mkdir_p(@test_files_path <> "/1")
File.touch(@test_files_path <> "/1/first")
File.touch(@test_files_path <> "/1/second")
expected_output = "first\nsecond\n"
assert expected_output == Porcelain.shell("ls #{@test_files_path}/1").out
end
So here we're just using this like a rich man's
System.cmd
, where we get more
interesting information back and don't have to receive the response ourselves.
shell
is one way to do this, and another is
exec
. Exec won't launch a shell
to run the program in, so you'll need to hand your executable a list of
arguments as that's something the shell normally does:
test "evaluating and getting a response" do
File.mkdir_p(@test_files_path <> "/1")
File.touch(@test_files_path <> "/1/first")
File.touch(@test_files_path <> "/1/second")
expected_output = "first\nsecond\n"
assert expected_output == Porcelain.shell("ls #{@test_files_path}/1").out
assert expected_output == Porcelain.exec("ls", ["#{@test_files_path}/1"]).out
end
For something like ls, there's no need to do anything interesting and async, so we'll leave that one there. You can also manage the pipelines in and out of your Porcelain spawned processes:
test "managing pipelines" do
opus = """
poppycock
garnish
rutabaga
pipsqueak
"""
sorted_opus = """
garnish
pipsqueak
poppycock
rutabaga
"""
File.mkdir_p(@test_files_path <> "/2")
text_file = @test_files_path <> "/2/text_file"
output_file = @test_files_path <> "/2/output_file"
File.write!(text_file, opus)
Porcelain.exec("sort", [], in: {:path, text_file}, out: {:append, output_file})
assert sorted_opus == File.read!(output_file)
end
Go ahead and run it....and it will hang and hang, and tell you that isn't using the goon driver. It will use the goon driver if goon is in your path. Goon is a companion binary for porcelain that provides some features that erlang's port executables layer just doesn't offer, and one of those features causes grief in this example. In the resources section I've linked to goon's project page, which has downloads for various architectures. At any rate, I'll move the driver into my path and it will get picked up now:
mv ~/tmp/goon ~/bin/goon
Alright, now I'll run the test again...and it passes. So you can actually pass in Elixir streams or files as input, which is pretty neat, and you can do the same for output. Let's try that:
numstream = Stream.cycle([1,2,3]) |> Stream.take(10)
Porcelain.exec("sort", [], in: numstream, out: {:path, output_file})
assert <<1,2,3,1,2,3,1,2,3,1>> == File.read!(output_file)
Alright, so if you run this test...it fails. There's an extra <<10>> at the end of the binary. Turns out that's a newline character...I'm not 100% sure why it ended up there, but let's just change our assertion now that we know the behaviour:
assert <<1,2,3,1,2,3,1,2,3,1>> <> "\n" == File.read!(output_file)
OK, run the tests, and they pass. You can also pass the output into anything that implements Collectable.
Let's move on to running a bash shell in a port and interacting with it:
test "interacting with a bash shell" do
alias Porcelain.Process, as: Proc
# So we'll spawn the bash shell, telling it to receive for its stdin and
# send us its stdout...we'll also grab a reference to its pid for later
proc = %Proc{pid: pid} = Porcelain.spawn("bash", ["--noediting", "-i"], in: :receive, out: {:send, self()})
# Now let's make a file with some known content using normal bash commands
Proc.send_input(proc, "echo foo > #{@test_files_path}/foo\n")
# We'll cat the file out
Proc.send_input(proc, "cat #{@test_files_path}/foo\n")
# And we'll verify that we received the file's data. There's an extra
# newline because cat injected one for the prompt
assert_receive {pid, :data, "foo\n"}, 1000
end
OK, run the tests...and they pass.
Alright, so our grand finale in Episode 068 involved spawning vim inside of our interactive bash process and interacting with it via messages. We can reproduce that, but there's a bit of non-determinism we have to deal with via the anti-glory of sleep statements. At any rate, here it is:
test "dat vim" do
alias Porcelain.Process, as: Proc
# Again we spawn bash, same as the last test
proc = %Proc{pid: pid} = Porcelain.spawn("bash", ["--noediting", "-i"], in: :receive, out: {:send, self()})
# We'll open a file in vim
Proc.send_input(proc, "vim #{@test_files_path}/vimfile\n")
# Here we have to wait for a little bit for vim to get ready for input
:timer.sleep 1000
# Enter insert mode, type some text
Proc.send_input(proc, "izomg hi there")
# Again, wait for a little bit for vim....
:timer.sleep 1000
# We'll leave insert mode and write the file and quit
esc = <<27>>
Proc.send_input(proc, esc)
Proc.send_input(proc, ":wq\n")
:timer.sleep 1000
# Now let's cat the file out
Proc.send_input(proc, "cat #{@test_files_path}/vimfile\n")
# And if everything worked, this should be what got catted out
assert_receive {pid, :data, "zomg hi there\n"}, 1000
end
OK, run the tests...and they SHOULD pass. Who knows, due to the non-determinism :)
Summary
In today's episode we looked at using Porcelain as a nicer interface for interacting with remote OS processes. It's got a much more pleasant interface for sending commands in, it has less arcane syntax for spawning processes with certain interesting options, and it has some features that you just can't get with the Port module, like streaming from an Elixir stream into stdin of a process. It looks great, and I expect I'll use it quite frequently. Hope you enjoyed it. See you soon!
Comments for Porcelain