Categorygithub.com/crowdigit/exec
repositorypackage
0.0.2
Repository: https://github.com/crowdigit/exec.git
Documentation: pkg.go.dev

# Packages

No description provided by the author

# README

crowdigit/exec

Go Reference

Wrapper library for os/exec with utilities.

Usage

Please see package example

CommandProvider/Command

Interface/wrapper type for exec.Command, exec.CommandContext.

1. Initializing System Command

cp := exec.NewCommandProvider()
// CommandOpts's value is assigned to *exec.Cmd exported fields.
opt := exec.CommandOpts{
    Path: "ls",
    Args: []string{"."},
    // ...
}
cmd := cp.Command(opt)
// or with kill (cancel)
// cmd := cp.CommandContext(ctx, opt)

2. Executing Command

Command interface provides the same receivers as *exec.Cmd, you can use Output, Start, etc., anything you want, the same way as you are using *exec.Cmd.

result, err := cmd.Output()
if err != nil {
    // oh no
}

3. Accessing Command fields

Plus, it add some receivers to access exported field from *exec.Cmd, (complete list is not added yet).

cmd := cp.Command(opt)
// *exec.Path field value is not accessible via [Command] interface
path := cmd.Path()

PipeSpec, Pipeline

These are utilities for commonly used system command piping operation.

1. Initialization

// 1. cancel func is required to handle error situations
ctx, kill := context.WithCancel(context.Background())
defer kill()

// 2. Define command specs to build command pipeline
// Following results in the same thing as "ls -1 | tail" in bash
pipespecs := []exec.PipeSpec{
    {
        CmdOpt: exec.CommandOpts{Path: "ls -1", Args: []string{}},
        Next:   exec.Stdout,
    },
    {
        CmdOpt: exec.CommandOpts{Path: "tail", Args: []string{}},
        Next:   exec.Stdout,
    },
}

// 3. Initialize pipeline, it does not spawn processes yet.
pipeline, err := exec.NewPipeline(ctx, cp, pipespecs)
if err != nil {
    return fmt.Errorf("failed to initialize pipeline: %w", err)
}

You can also pipe stderr instead of stdout, or read stderr while piping stdout to next process. See package example.

2. Start Pipeline

// 1. Start pipeline commands, it actually spawns process.
// There may be partial success state if some command fail.
// Client is responsible to call Pipeline.Cancel regardless to Pipeline.Start result.
defer pipeline.Cancel(kill)
if err = pipeline.Start(); err != nil {
    return fmt.Errorf("failed to start pipeline: %w", err)
}

// 2. Client must consume Pipeline.Output stream (last process's output stream) on separate goroutine,
// (otherwise it blocks) if it was not specified to pipe to Null.
// In this example, it prints the stdout to parent's stdout.
chPipeErr := make(chan error)
go func() {
    for {
        subBuffer := make([]byte, 1024)
        read, err := pipeline.Output().Read(subBuffer)
        if read > 0 {
            fmt.Println(string(subBuffer[:read]))
        }
        if errors.Is(err, io.EOF) {
            close(chPipeErr)
            return
        } else if err != nil {
            chPipeErr <- err
            return
        }
    }
}()
if err := <-chPipeErr; err != nil {
    return fmt.Errorf("failed to operate on pipe: %w", err)
}

3. Wait & Getting results

// Pipeline.Any blocks until any or all of command in pipeline has exited.
if errs := pipeline.Any(kill); errs != nil {
    fmt.Fprintln(os.Stderr, errs)
    for i, err := range errs {
        fmt.Println(err)
    }
    return errs
}

If Pipeline.Any returned nil slice, all commands has exited normally. Otherwise it's slice of PipelineError which implements error, Unwrap interface and some context values to provide information which command failed.

for _, err := range errors {
    _ = err.Index // command index in pipeline
    _ = err.Path // command Path e.g. /bin/ls
    _ = err.Unwrap() // original error that *exec.Cmd.Wait returned
}

After Pipeline.Any has returned, optional output stream writers are filled with data. See package example for how to read stdout/stderr.

Why?

I write program whose main purpose is to invoke system command a lot. (sure, writing bash script is more efficient but Golang is fun). I often find myself writing interface type for *exec.Cmd and its mocking interface to write tests. So I made it reusable.

Mock interface is included in /mocks directory ftw, functions accepting CommandProvider (rather than calling exec.Command) is more testable I think.1

Footnotes

  1. Some may argue that we shouldn't test that code. That may be correct, but I think if its role is like shell script (e.g. execute command and parse standard output), I think it is good to test that code.