Skip to content

Commit

Permalink
feat: add WithContext builder
Browse files Browse the repository at this point in the history
  • Loading branch information
maxksec committed Aug 29, 2024
1 parent 3c3977e commit 576c9d5
Show file tree
Hide file tree
Showing 2 changed files with 17 additions and 107 deletions.
113 changes: 11 additions & 102 deletions script.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type Pipe struct {
Reader ReadAutoCloser
stdout, stderr io.Writer
httpClient *http.Client

ctx context.Context
// because pipe stages are concurrent, protect 'err'
mu *sync.Mutex
err error
Expand Down Expand Up @@ -64,16 +64,6 @@ func Exec(cmdLine string) *Pipe {
return NewPipe().Exec(cmdLine)
}

// Exec creates a pipe that runs cmdLine as an external command and produces
// its combined output (interleaving standard output and standard error). See
// [Pipe.Exec] for error handling details.
//
// Use [Pipe.Exec] to send the contents of an existing pipe to the command's
// standard input.
func ExecContext(ctx context.Context, cmdLine string) *Pipe {
return NewPipe().ExecContext(ctx, cmdLine)
}

// File creates a pipe that reads from the file path.
func File(path string) *Pipe {
f, err := os.Open(path)
Expand Down Expand Up @@ -175,6 +165,7 @@ func NewPipe() *Pipe {
return &Pipe{
Reader: ReadAutoCloser{},
mu: new(sync.Mutex),
ctx: context.Background(),
stdout: os.Stdout,
httpClient: http.DefaultClient,
}
Expand Down Expand Up @@ -392,48 +383,7 @@ func (p *Pipe) Exec(cmdLine string) *Pipe {
if err != nil {
return err
}
cmd := exec.Command(args[0], args[1:]...)
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()
})
}

// Exec runs cmdLine as an external command, sending it the contents of the
// pipe as input, and produces the command's standard output (see below for
// error output). The effect of this is to filter the contents of the pipe
// through the external command. It also allows passing context.Context making
// it cancellable
//
// # Error handling
//
// If the command had a non-zero exit status, the pipe's error status will also
// be set to the string “exit status X”, where X is the integer exit status.
// Even in the event of a non-zero exit status, the command's output will still
// be available in the pipe. This is often helpful for debugging. However,
// because [Pipe.String] is a no-op if the pipe's error status is set, if you
// want output you will need to reset the error status before calling
// [Pipe.String].
//
// If the command writes to its standard error stream, this will also go to the
// pipe, along with its standard output. However, the standard error text can
// instead be redirected to a supplied writer, using [Pipe.WithStderr].
func (p *Pipe) ExecContext(ctx context.Context, cmdLine string) *Pipe {
return p.Filter(func(r io.Reader, w io.Writer) error {
args, err := shell.Fields(cmdLine, nil)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
cmd := exec.CommandContext(p.ctx, args[0], args[1:]...)
cmd.Stdin = r
cmd.Stdout = w
cmd.Stderr = w
Expand Down Expand Up @@ -474,7 +424,7 @@ func (p *Pipe) ExecForEach(cmdLine string) *Pipe {
if err != nil {
return err
}
cmd := exec.Command(args[0], args[1:]...)
cmd := exec.CommandContext(p.ctx, args[0], args[1:]...)
cmd.Stdout = w
cmd.Stderr = w
if p.stderr != nil {
Expand All @@ -495,54 +445,6 @@ func (p *Pipe) ExecForEach(cmdLine string) *Pipe {
})
}

// ExecContextForEach 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.ExecContext] for error handling details.
// It also allows to pass context.Context allowing to cancel the execution
//
// This is mostly useful for substituting data into commands using Go template
// syntax. For example:
//
// ListFiles("*").ExecForEach("touch {{.}}").Wait()
func (p *Pipe) ExecContextForEach(ctx context.Context, cmdLine string) *Pipe {
tpl, err := template.New("").Parse(cmdLine)
if err != nil {
return p.WithError(err)
}
return p.Filter(func(r io.Reader, w io.Writer) error {
scanner := newScanner(r)
for scanner.Scan() {
cmdLine := new(strings.Builder)
err := tpl.Execute(cmdLine, scanner.Text())
if err != nil {
return err
}
args, err := shell.Fields(cmdLine.String(), nil)
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
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)
continue
}
err = cmd.Wait()
if err != nil {
fmt.Fprintln(cmd.Stderr, err)
continue
}
}
return scanner.Err()
})
}


var exitStatusPattern = regexp.MustCompile(`exit status (\d+)$`)

// ExitStatus returns the integer exit status of a previous command (for
Expand Down Expand Up @@ -998,6 +900,13 @@ func (p *Pipe) WithStdout(w io.Writer) *Pipe {
return p
}

// WithContext sets context.Context for the pipe. Adds support for graceful pipe
// shutdown. Currently works with [Pipe.Exec] and [Pipe.ExecForEach]
func (p *Pipe) WithContext(ctx context.Context) *Pipe {
p.ctx = ctx
return p
}

// WriteFile writes the pipe's contents to the file path, truncating it if it
// exists, and returns the number of bytes successfully written, or an error.
func (p *Pipe) WriteFile(path string) (int64, error) {
Expand Down
11 changes: 6 additions & 5 deletions script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"strings"
"testing"
"testing/iotest"
"time"

"github.com/bitfield/script"
"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -1225,13 +1226,13 @@ func TestExecContextCencel(t *testing.T) {
// 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)
ctx, cancel := context.WithTimeout(context.Background(), 1)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
p := script.ExecContext(ctx, "sleep 100")
p := script.NewPipe().WithContext(ctx).Exec("sleep 1")
p.Wait()
err := p.Error()
if err == nil {
t.Fatal("when command is canceled there should be an error")
err := p.Error()
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal("context should timeout")
}
t.Log(p.ExitStatus())
}
Expand Down

0 comments on commit 576c9d5

Please sign in to comment.