Skip to content

Commit

Permalink
Add a higher level API for CLI (#3)
Browse files Browse the repository at this point in the history
* Add a higher level API (see command package) and examples
  • Loading branch information
plar committed Feb 16, 2024
1 parent b59d7df commit 4b371b6
Show file tree
Hide file tree
Showing 30 changed files with 1,695 additions and 1 deletion.
43 changes: 43 additions & 0 deletions command/applier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2024 The Kanister Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package command

import "github.com/kanisterio/safecli"

// Applier defines the interface for applying arguments to the command.
type Applier interface {
// Apply applies arguments to the command.
Apply(safecli.CommandAppender) error
}

// apply appends multiple arguments to the command.
// If any of the arguments encounter an error during the apply process,
// the error is returned and no changes are made to the command.
// If no error, the arguments are appended to the command.
func apply(cmd safecli.CommandAppender, args ...Applier) error {
// create a new subcmd builder which will be used to apply the arguments
// to avoid mutating the command if an error is encountered.
subcmd := safecli.NewBuilder()
for _, arg := range args {
if arg == nil { // if the param is nil, skip it
continue
}
if err := arg.Apply(subcmd); err != nil {
return err
}
}
cmd.Append(subcmd)
return nil
}
99 changes: 99 additions & 0 deletions command/arg.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2024 The Kanister Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package command

import (
"github.com/kanisterio/safecli"
)

// errorArgument is a simple implementation of the Applier interface
// that always returns an error when applied.
type errorArgument struct {
err error // error to return when applied
}

// Apply does nothing except return an error if one is set.
func (e errorArgument) Apply(cmd safecli.CommandAppender) error {
return e.err
}

// NewErrorArgument creates a new argument with a given error.
// It is useful for creating an argument that always fails when applied.
func NewErrorArgument(err error) Applier {
return errorArgument{err: err}
}

// noopArgument is a simple implementation of the Applier interface that does nothing.
type noopArgument struct{}

func (noopArgument) Apply(safecli.CommandAppender) error {
return nil
}

// NewNoopArgument creates a new argument that does nothing when applied.
func NewNoopArgument() Applier {
return noopArgument{}
}

// argument defines an argument with the given name.
// If the argument is redacted, it is appended as redacted.
type argument struct {
name string
isRedacted bool
}

// Apply appends the argument to the command.
func (a argument) Apply(cmd safecli.CommandAppender) error {
append := cmd.AppendLoggable
if a.isRedacted {
append = cmd.AppendRedacted
}
append(a.name)
return nil
}

// newArgument creates a new argument with a given name and .
func newArgument(name string, isRedacted bool) Applier {
if name == "" {
return NewErrorArgument(ErrInvalidArgumentName)
}
return argument{
name: name,
isRedacted: isRedacted,
}
}

// NewArgument creates a new argument with a given name.
func NewArgument(name string) Applier {
return newArgument(name, false)
}

// NewRedactedArgument creates a new redacted argument with a given name.
func NewRedactedArgument(name string) Applier {
return newArgument(name, true)
}

// Arguments defines a collection of command arguments.
type Arguments []Applier

// Apply applies the flags to the CLI.
func (args Arguments) Apply(cli safecli.CommandAppender) error {
return apply(cli, args...)
}

// NewArguments creates a new collection of arguments.
func NewArguments(args ...Applier) Applier {
return Arguments(args)
}
92 changes: 92 additions & 0 deletions command/arg_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2024 The Kanister Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package command_test

import (
"testing"

"gopkg.in/check.v1"

"github.com/pkg/errors"

"github.com/kanisterio/safecli"
"github.com/kanisterio/safecli/command"
"github.com/kanisterio/safecli/test"
)

var (
ErrArgument = errors.New("arg error")
)

// MockArg is a mock implementation of the Applier interface.
type MockArg struct {
name string
err error
}

func (m *MockArg) Apply(cli safecli.CommandAppender) error {
cli.AppendLoggable(m.name)
return m.err
}

func TestArguments(t *testing.T) { check.TestingT(t) }

var _ = check.Suite(&test.ArgumentSuite{Cmd: "cmd", Arguments: []test.ArgumentTest{
{
Name: "NewErrorArgument without error",
Argument: command.NewErrorArgument(nil),
ExpectedCLI: []string{"cmd"},
},
{
Name: "NewErrorArgument with error",
Argument: command.NewErrorArgument(ErrArgument),
ExpectedErr: ErrArgument,
},
{
Name: "NewArgument",
Argument: command.NewArgument("arg1"),
ExpectedCLI: []string{"cmd", "arg1"},
},
{
Name: "NewArgument with empty name",
Argument: command.NewArgument(""),
ExpectedErr: command.ErrInvalidArgumentName,
},
{
Name: "NewRedactedArgument",
Argument: command.NewRedactedArgument("arg1"),
ExpectedCLI: []string{"cmd", "arg1"},
ExpectedLog: "cmd <****>",
},
{
Name: "NewRedactedArgument with empty name",
Argument: command.NewRedactedArgument(""),
ExpectedErr: command.ErrInvalidArgumentName,
},
{
Name: "NewArguments",
Argument: command.NewArguments(
command.NewArgument("arg1"),
nil, // should be skipped
command.NewRedactedArgument("arg2"),
),
ExpectedCLI: []string{"cmd", "arg1", "arg2"},
ExpectedLog: "cmd arg1 <****>",
},
{
Name: "NewArguments without args",
ExpectedCLI: []string{"cmd"},
},
}})
30 changes: 30 additions & 0 deletions command/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright 2024 The Kanister Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package command

import "github.com/kanisterio/safecli"

// New creates a new safecli.Builder with the given command name and arguments.
// If the command name is empty, it will be omitted from the output.
func New(cmdName string, args ...Applier) (*safecli.Builder, error) {
cmd := safecli.NewBuilder()
if cmdName != "" {
cmd.AppendLoggable(cmdName)
}
if err := apply(cmd, args...); err != nil {
return nil, err
}
return cmd, nil
}
68 changes: 68 additions & 0 deletions command/command_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2024 The Kanister Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package command_test

import (
"strings"
"testing"

"github.com/kanisterio/safecli/command"
"gopkg.in/check.v1"
)

func TestCommand(t *testing.T) { check.TestingT(t) }

type CommandSuite struct{}

var _ = check.Suite(&CommandSuite{})

func (s *CommandSuite) TestCommandNewOK(c *check.C) {
cli := []string{
"cmd",
"--log-level=info",
"--password=secret",
"arg",
"--dest=/tmp/dir",
"--read-only",
}
log := []string{
"cmd",
"--log-level=info",
"--password=<****>",
"arg",
"--dest=/tmp/dir",
"--read-only",
}
cmd, err := command.New("cmd", []command.Applier{
command.NewOptionWithArgument("--log-level", "info"),
command.NewOptionWithRedactedArgument("--password", "secret"),
command.NewArgument("arg"),
command.NewOptionWithArgument("--dest", "/tmp/dir"),
command.NewOption("--read-only", true),
}...)
c.Assert(err, check.IsNil)
c.Assert(cmd.Build(), check.DeepEquals, cli)
c.Assert(cmd.String(), check.Equals, strings.Join(log, " "))
}

func (s *CommandSuite) TestCommandNewError(c *check.C) {
cmd, err := command.New("cmd", []command.Applier{
command.NewOptionWithArgument("--log-level", "info"),
command.NewOptionWithRedactedArgument("--password", "secret"),
command.NewArgument(""), // error argument
}...)
c.Assert(cmd, check.IsNil)
c.Assert(err, check.Equals, command.ErrInvalidArgumentName)
}
32 changes: 32 additions & 0 deletions command/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package command

// Copyright 2024 The Kanister Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

//
// The command package is used to define CLI (Command Line Interface) commands along with their arguments.
//
// Command line arguments are the whitespace-separated tokens given in the shell command used to invoke the program.
//
// A token prefixed with a hyphen delimiter (`-`) is known as an *option*. For example, `-o` or `--option`.
//
// An option may or may not have an associated argument. For example, `--option=value`.
//
// A token without a hyphen delimiter (`-`) is considered an *argument*. For example, `arg1` or `arg2`.
//
// The command package provides a set of interfaces and types for defining and applying arguments to commands.
//
// Check safecli/examples/kopia package for usage of the command package.
//
26 changes: 26 additions & 0 deletions command/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2024 The Kanister Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package command

import (
"github.com/pkg/errors"
)

var (
// ErrInvalidArgumentName is returned when the argument name is empty.
ErrInvalidArgumentName = errors.New("argument name is empty")
// ErrInvalidOptionName is returned when the option name is empty or has no hyphen prefix.
ErrInvalidOptionName = errors.New("option name is empty or has no hyphen prefix")
)
Loading

0 comments on commit 4b371b6

Please sign in to comment.