From aba9817afb969d53cb660592a00440110bfe3db9 Mon Sep 17 00:00:00 2001 From: paulc <212168+paulc@users.noreply.github.com> Date: Fri, 25 Aug 2023 21:33:48 +0100 Subject: [PATCH] Implement Execv function (equivalent to Exec but with args passed as []string vs inetrpolated into single cmdLine) --- README.md | 12 +++++++ script.go | 34 +++++++++++++++++++ script_test.go | 44 +++++++++++++++++++++++++ script_unix_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 168 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a06a70c..ef26e4e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ If you're already familiar with shell scripting and the Unix toolset, here is a | Unix / shell | `script` equivalent | | ------------------ | ------------------- | | (any program name) | [`Exec`](https://pkg.go.dev/github.com/bitfield/script#Exec) | +| | [`Execv`](https://pkg.go.dev/github.com/bitfield/script#Execv) | | `[ -f FILE ]` | [`IfExists`](https://pkg.go.dev/github.com/bitfield/script#IfExists) | | `>` | [`WriteFile`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WriteFile) | | `>>` | [`AppendFile`](https://pkg.go.dev/github.com/bitfield/script#Pipe.AppendFile) | @@ -177,6 +178,15 @@ PING 127.0.0.1 (127.0.0.1): 56 data bytes ... ``` +If we are construcing Exec arguments dynamically it can be easier to use Execv where we can pass cmd and []args separately without having to escape args. + +```go +args := []string{}; +args = append(args,"127.0.0.1") +script.Exec("ping",args).Stdout() +``` + + In the `ping` example, we knew the exact arguments we wanted to send the command, and we just needed to run it once. But what if we don't know the arguments yet? We might get them from the user, for example. We might like to be able to run the external command repeatedly, each time passing it the next line of data from the pipe as an argument. No worries: @@ -271,6 +281,7 @@ These are functions that create a pipe with a given contents: | [`Do`](https://pkg.go.dev/github.com/bitfield/script#Do) | HTTP response | [`Echo`](https://pkg.go.dev/github.com/bitfield/script#Echo) | a string | [`Exec`](https://pkg.go.dev/github.com/bitfield/script#Exec) | command output +| [`Execv`](https://pkg.go.dev/github.com/bitfield/script#Execv) | command output (args passed as []) | [`File`](https://pkg.go.dev/github.com/bitfield/script#File) | file contents | [`FindFiles`](https://pkg.go.dev/github.com/bitfield/script#FindFiles) | recursive file listing | [`Get`](https://pkg.go.dev/github.com/bitfield/script#Get) | HTTP response @@ -293,6 +304,7 @@ Filters are methods on an existing pipe that also return a pipe, allowing you to | [`Do`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Do) | response to supplied HTTP request | | [`Echo`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Echo) | all input replaced by given string | | [`Exec`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Exec) | filtered through external command | +| [`Execv`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Execv) | filtered through external command (args passed as []) | | [`ExecForEach`](https://pkg.go.dev/github.com/bitfield/script#Pipe.ExecForEach) | execute given command template for each line of input | | [`Filter`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Filter) | user-supplied function filtering a reader to a writer | | [`FilterLine`](https://pkg.go.dev/github.com/bitfield/script#Pipe.FilterLine) | user-supplied function filtering each line to a string| diff --git a/script.go b/script.go index 213c8ce..96e9668 100644 --- a/script.go +++ b/script.go @@ -63,6 +63,13 @@ func Exec(cmdLine string) *Pipe { return NewPipe().Exec(cmdLine) } +// Execv creates a pipe that runs cmd as an external command with args []args +// and produces its combined output (interleaving standard output and standard +// error). See [Pipe.Execv] for error handling details. +func Execv(cmd string, args []string) *Pipe { + return NewPipe().Execv(cmd, args) +} + // File creates a pipe that reads from the file path. func File(path string) *Pipe { f, err := os.Open(path) @@ -397,6 +404,33 @@ func (p *Pipe) Exec(cmdLine string) *Pipe { }) } +// Execv behaves identically to Exec (runs external command as part of the +// pipe) however instead of accepting a single cmdLine string takes the cmd and +// []args separately. This avoids the need to quote args and reparse args +// if they are generated dynamically and any potential assciated string +// interpolation bugs +// +// # Error handling +// +// The error handling is the same as Exec +func (p *Pipe) Execv(cmd string, args []string) *Pipe { + return p.Filter(func(r io.Reader, w io.Writer) error { + cmd := exec.Command(cmd, args...) + cmd.Stdin = r + cmd.Stdout = w + cmd.Stderr = w + if p.stderr != nil { + cmd.Stderr = p.stderr + } + err := cmd.Start() + if err != nil { + fmt.Fprintln(cmd.Stderr, err) + return err + } + return cmd.Wait() + }) +} + // ExecForEach renders cmdLine as a Go template for each line of input, running // the resulting command, and produces the combined output of all these // commands in sequence. See [Pipe.Exec] for error handling details. diff --git a/script_test.go b/script_test.go index dd12e3b..987301b 100644 --- a/script_test.go +++ b/script_test.go @@ -1201,6 +1201,50 @@ func TestExecRunsGoHelpAndGetsUsageMessage(t *testing.T) { } } +func TestExecvErrorsWhenTheSpecifiedCommandDoesNotExist(t *testing.T) { + t.Parallel() + p := script.Execv("doesntexist", []string{"A", "B"}) + p.Wait() + if p.Error() == nil { + t.Error("want error running non-existent command") + } +} + +func TestExecvRunsGoWithNoArgsAndGetsUsageMessagePlusErrorExitStatus2(t *testing.T) { + t.Parallel() + // We can't make many cross-platform assumptions about what external + // commands will be available, but it seems logical that 'go' would be + // (though it may not be in the user's path) + p := script.Execv("go", []string{}) + output, err := p.String() + if err == nil { + t.Fatal("want error when command returns a non-zero exit status") + } + if !strings.Contains(output, "Usage") { + t.Fatalf("want output containing the word 'Usage', got %q", output) + } + want := 2 + got := p.ExitStatus() + if want != got { + t.Errorf("want exit status %d, got %d", want, got) + } +} + +func TestExecvRunsGoHelpAndGetsUsageMessage(t *testing.T) { + t.Parallel() + p := script.Execv("go", []string{"help"}) + if p.Error() != nil { + t.Fatal(p.Error()) + } + output, err := p.String() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(output, "Usage") { + t.Fatalf("want output containing the word 'Usage', got %q", output) + } +} + func TestFileOutputsContentsOfSpecifiedFile(t *testing.T) { t.Parallel() want := "This is the first line in the file.\nHello, world.\nThis is another line in the file.\n" diff --git a/script_unix_test.go b/script_unix_test.go index 40a98c8..584e3df 100644 --- a/script_unix_test.go +++ b/script_unix_test.go @@ -36,6 +36,40 @@ func TestExecRunsShWithEchoHelloAndGetsOutputHello(t *testing.T) { } } +func TestExecvWithDynamicArgs(t *testing.T) { + t.Parallel() + args := []string{"-c"} + args = append(args, "echo hello") + p := script.Execv("sh", args) + if p.Error() != nil { + t.Fatal(p.Error()) + } + want := "hello\n" + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if want != got { + t.Error(cmp.Diff(want, got)) + } +} + +func TestExecvRunsShWithEchoHelloAndGetsOutputHello(t *testing.T) { + t.Parallel() + p := script.Execv("sh", []string{"-c", "echo hello"}) + if p.Error() != nil { + t.Fatal(p.Error()) + } + want := "hello\n" + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if want != got { + t.Error(cmp.Diff(want, got)) + } +} + func TestExecRunsShWithinShWithEchoInceptionAndGetsOutputInception(t *testing.T) { t.Parallel() p := script.Exec("sh -c 'sh -c \"echo inception\"'") @@ -52,6 +86,22 @@ func TestExecRunsShWithinShWithEchoInceptionAndGetsOutputInception(t *testing.T) } } +func TestExecvRunsShWithinShWithEchoInceptionAndGetsOutputInception(t *testing.T) { + t.Parallel() + p := script.Execv("sh", []string{"-c", "sh -c \"echo inception\""}) + if p.Error() != nil { + t.Fatal(p.Error()) + } + want := "inception\n" + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if want != got { + t.Error(cmp.Diff(want, got)) + } +} + func TestExecErrorsRunningShellCommandWithUnterminatedStringArgument(t *testing.T) { t.Parallel() p := script.Exec("sh -c 'echo oh no") @@ -61,6 +111,15 @@ func TestExecErrorsRunningShellCommandWithUnterminatedStringArgument(t *testing. } } +func TestExecvErrorsRunningShellCommandWithUnterminatedStringArgument(t *testing.T) { + t.Parallel() + p := script.Execv("sh", []string{"-c", "'echo oh no"}) + p.Wait() + if p.Error() == nil { + t.Error("want error running 'sh' command line containing unterminated string") + } +} + func TestExecForEach_RunsEchoWithABCAndGetsOutputABC(t *testing.T) { t.Parallel() p := script.Echo("a\nb\nc\n").ExecForEach("echo {{.}}") @@ -112,6 +171,12 @@ func ExampleExec_ok() { // Hello, world! } +func ExampleExecv_ok() { + script.Execv("echo", []string{"Hello, world!"}).Stdout() + // Output: + // Hello, world! +} + func ExampleFindFiles() { script.FindFiles("testdata/multiple_files_with_subdirectory").Stdout() // Output: @@ -129,8 +194,14 @@ func ExampleIfExists_exec() { // hello } +func ExampleIfExists_execv() { + script.IfExists("./testdata/hello.txt").Execv("echo", []string{"hello"}).Stdout() + // Output: + // hello +} + func ExampleIfExists_noExec() { - script.IfExists("doesntexist").Exec("echo hello").Stdout() + script.IfExists("doesntexist").Execv("echo", []string{"hello"}).Stdout() // Output: // } @@ -192,6 +263,12 @@ func ExamplePipe_Exec() { // HELLO, WORLD! } +func ExamplePipe_Execv() { + script.Echo("Hello, world!").Execv("tr", []string{"a-z", "A-Z"}).Stdout() + // Output: + // HELLO, WORLD! +} + func ExamplePipe_ExecForEach() { script.Echo("a\nb\nc\n").ExecForEach("echo {{.}}").Stdout() // Output: