Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cli: public commands API #232

Merged
merged 20 commits into from
Aug 1, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 95 additions & 156 deletions internals/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"os"
"os/user"
"path/filepath"
"regexp"
"strings"
"unicode"
"unicode/utf8"
Expand Down Expand Up @@ -49,70 +50,50 @@ var (
// the pebble client.
const defaultPebbleDir = "/var/lib/pebble/default"

type options struct {
Version func() `long:"version"`
}
// ErrExtraArgs is returned if extra arguments to a command are found
var ErrExtraArgs = fmt.Errorf("too many arguments for command")

type argDesc struct {
name string
desc string
}
// CmdInfo holds information needed by the CLI to execute commands and
// populate entries in the help manual.
type CmdInfo struct {
anpep marked this conversation as resolved.
Show resolved Hide resolved
// Name of the command
Name string

var optionsData options
// Summary is a single-line help string that will be displayed
// in the full Pebble help manual (i.e. help --all)
Summary string

// ErrExtraArgs is returned if extra arguments to a command are found
var ErrExtraArgs = fmt.Errorf("too many arguments for command")
// Description contains exhaustive documentation about the command,
// that will be reflected in the specific help manual for the
// command, and in the Pebble man page.
Description string

// Builder is a function that creates a new instance of the command
// struct containing an Execute(args []string) implementation.
Builder func() flags.Commander

// cmdInfo holds information needed to call parser.AddCommand(...).
type cmdInfo struct {
name, shortHelp, longHelp string
builder func() flags.Commander
hidden bool
optDescs map[string]string
argDescs []argDesc
alias string
extra func(*flags.Command)
// ArgsHelp (optional) contains help about the command-line arguments
// (including options) supported by the command.
//
// map[string]string{
// "--long-option": "my very long option",
// "-v": "verbose output",
anpep marked this conversation as resolved.
Show resolved Hide resolved
// "<change-id>": "named positional argument"
// }
ArgsHelp map[string]string
anpep marked this conversation as resolved.
Show resolved Hide resolved

// Whether to pass all arguments after the first non-option as remaining
anpep marked this conversation as resolved.
Show resolved Hide resolved
// command line arguments. This is equivalent to strict POSIX processing.
PassAfterNonOption bool
}

// commands holds information about all non-debug commands.
var commands []*cmdInfo

// debugCommands holds information about all debug commands.
var debugCommands []*cmdInfo
var commands []*CmdInfo

// addCommand replaces parser.addCommand() in a way that is compatible with
// AddCommand replaces parser.addCommand() in a way that is compatible with
// re-constructing a pristine parser.
func addCommand(name, shortHelp, longHelp string, builder func() flags.Commander, optDescs map[string]string, argDescs []argDesc) *cmdInfo {
anpep marked this conversation as resolved.
Show resolved Hide resolved
info := &cmdInfo{
name: name,
shortHelp: shortHelp,
longHelp: longHelp,
builder: builder,
optDescs: optDescs,
argDescs: argDescs,
}
func AddCommand(info *CmdInfo) {
anpep marked this conversation as resolved.
Show resolved Hide resolved
commands = append(commands, info)
anpep marked this conversation as resolved.
Show resolved Hide resolved
return info
}

// addDebugCommand replaces parser.addCommand() in a way that is
// compatible with re-constructing a pristine parser. It is meant for
// adding debug commands.
func addDebugCommand(name, shortHelp, longHelp string, builder func() flags.Commander, optDescs map[string]string, argDescs []argDesc) *cmdInfo {
anpep marked this conversation as resolved.
Show resolved Hide resolved
info := &cmdInfo{
name: name,
shortHelp: shortHelp,
longHelp: longHelp,
builder: builder,
optDescs: optDescs,
argDescs: argDescs,
}
debugCommands = append(debugCommands, info)
return info
}

type parserSetter interface {
setParser(*flags.Parser)
}

func lintDesc(cmdName, optName, desc, origDesc string) {
Expand Down Expand Up @@ -157,148 +138,106 @@ func fixupArg(optName string) string {
return optName
}

type clientSetter interface {
setClient(*client.Client)
}

type clientMixin struct {
client *client.Client
}

type clientSetter interface {
anpep marked this conversation as resolved.
Show resolved Hide resolved
setClient(*client.Client)
}

func (ch *clientMixin) setClient(cli *client.Client) {
ch.client = cli
}

type parserSetter interface {
setParser(*flags.Parser)
}

type options struct {
anpep marked this conversation as resolved.
Show resolved Hide resolved
Version func() `long:"version" hidden:"yes" description:"Print the version and exit"`
}

// Parser creates and populates a fresh parser.
// Since commands have local state a fresh parser is required to isolate tests
// from each other.
func Parser(cli *client.Client) *flags.Parser {
optionsData.Version = func() {
printVersions(cli)
panic(&exitStatus{0})
// Implement --version by default on every command
anpep marked this conversation as resolved.
Show resolved Hide resolved
defaultOptions := options{
Version: func() {
printVersions(cli)
panic(&exitStatus{0})
},
}
flagopts := flags.Options(flags.PassDoubleDash)
parser := flags.NewParser(&optionsData, flagopts)

flagOpts := flags.Options(flags.PassDoubleDash)
parser := flags.NewParser(&defaultOptions, flagOpts)
parser.ShortDescription = "Tool to interact with pebble"
parser.LongDescription = longPebbleDescription
// hide the unhelpful "[OPTIONS]" from help output
parser.Usage = ""
if version := parser.FindOptionByLongName("version"); version != nil {
anpep marked this conversation as resolved.
Show resolved Hide resolved
version.Description = "Print the version and exit"
version.Hidden = true
}
// add --help like what go-flags would do for us, but hidden

// Add --help like what go-flags would do for us, but hidden
addHelp(parser)

// Regular expressions for positional and flag arguments
positionalRegexp := regexp.MustCompile(`^<[\w-]+>$`)
flagRegexp := regexp.MustCompile(`^-(\w|-[\w-]+)$`)
anpep marked this conversation as resolved.
Show resolved Hide resolved

// Add all regular commands
for _, c := range commands {
obj := c.builder()
obj := c.Builder()
if x, ok := obj.(clientSetter); ok {
x.setClient(cli)
}
if x, ok := obj.(parserSetter); ok {
x.setParser(parser)
}

cmd, err := parser.AddCommand(c.name, c.shortHelp, strings.TrimSpace(c.longHelp), obj)
cmd, err := parser.AddCommand(c.Name, c.Summary, strings.TrimSpace(c.Description), obj)
if err != nil {
logger.Panicf("cannot add command %q: %v", c.name, err)
logger.Panicf("cannot add command %q: %v", c.Name, err)
}
cmd.Hidden = c.hidden
if c.alias != "" {
cmd.Aliases = append(cmd.Aliases, c.alias)
}

opts := cmd.Options()
if c.optDescs != nil && len(opts) != len(c.optDescs) {
logger.Panicf("wrong number of option descriptions for %s: expected %d, got %d", c.name, len(opts), len(c.optDescs))
}
for _, opt := range opts {
name := opt.LongName
if name == "" {
name = string(opt.ShortName)
}
desc, ok := c.optDescs[name]
if !(c.optDescs == nil || ok) {
logger.Panicf("%s missing description for %s", c.name, name)
}
lintDesc(c.name, name, desc, opt.Description)
if desc != "" {
opt.Description = desc
cmd.PassAfterNonOption = c.PassAfterNonOption

flagHelp := map[string]string{}
positionalHelp := map[string]string{}

for specifier, help := range c.ArgsHelp {
if flagRegexp.MatchString(specifier) {
flagHelp[specifier] = help
} else if positionalRegexp.MatchString(specifier) {
positionalHelp[specifier] = help
} else {
logger.Panicf("invalid help specifier from %s: %s", c.Name, specifier)
anpep marked this conversation as resolved.
Show resolved Hide resolved
}
}

args := cmd.Args()
if c.argDescs != nil && len(args) != len(c.argDescs) {
logger.Panicf("wrong number of argument descriptions for %s: expected %d, got %d", c.name, len(args), len(c.argDescs))
}
for i, arg := range args {
name, desc := arg.Name, ""
if c.argDescs != nil {
name = c.argDescs[i].name
desc = c.argDescs[i].desc
}
lintArg(c.name, name, desc, arg.Description)
name = fixupArg(name)
arg.Name = name
arg.Description = desc
}
if c.extra != nil {
c.extra(cmd)
}
}
// Add the debug command
debugCommand, err := parser.AddCommand("debug", shortDebugHelp, longDebugHelp, &cmdDebug{})
debugCommand.Hidden = true
if err != nil {
logger.Panicf("cannot add command %q: %v", "debug", err)
}
// Add all the sub-commands of the debug command
for _, c := range debugCommands {
anpep marked this conversation as resolved.
Show resolved Hide resolved
obj := c.builder()
if x, ok := obj.(clientSetter); ok {
x.setClient(cli)
}
cmd, err := debugCommand.AddCommand(c.name, c.shortHelp, strings.TrimSpace(c.longHelp), obj)
if err != nil {
logger.Panicf("cannot add debug command %q: %v", c.name, err)
}
cmd.Hidden = c.hidden
// Make sure all argument descriptions are set
opts := cmd.Options()
if c.optDescs != nil && len(opts) != len(c.optDescs) {
logger.Panicf("wrong number of option descriptions for %s: expected %d, got %d", c.name, len(opts), len(c.optDescs))
if len(opts) != len(flagHelp) {
logger.Panicf("wrong number of flag descriptions for %s: expected %d, got %d", c.Name, len(opts), len(flagHelp))
anpep marked this conversation as resolved.
Show resolved Hide resolved
}
for _, opt := range opts {
name := opt.LongName
if name == "" {
name = string(opt.ShortName)
}
desc, ok := c.optDescs[name]
if !(c.optDescs == nil || ok) {
logger.Panicf("%s missing description for %s", c.name, name)
}
lintDesc(c.name, name, desc, opt.Description)
if desc != "" {
opt.Description = desc
if description, ok := flagHelp["--"+opt.LongName]; ok {
lintDesc(c.Name, opt.LongName, description, opt.Description)
opt.Description = description
} else if description, ok := flagHelp["-"+string(opt.ShortName)]; ok {
lintDesc(c.Name, string(opt.ShortName), description, opt.Description)
opt.Description = description
} else if !opt.Hidden {
logger.Panicf("%s missing description for %s", c.Name, opt)
anpep marked this conversation as resolved.
Show resolved Hide resolved
}
}

args := cmd.Args()
if c.argDescs != nil && len(args) != len(c.argDescs) {
logger.Panicf("wrong number of argument descriptions for %s: expected %d, got %d", c.name, len(args), len(c.argDescs))
}
for i, arg := range args {
name, desc := arg.Name, ""
if c.argDescs != nil {
name = c.argDescs[i].name
desc = c.argDescs[i].desc
for _, arg := range args {
if description, ok := positionalHelp[arg.Name]; ok {
lintArg(c.Name, arg.Name, description, arg.Description)
arg.Name = fixupArg(arg.Name)
arg.Description = description
}
lintArg(c.name, name, desc, arg.Description)
name = fixupArg(name)
arg.Name = name
arg.Description = desc
}
}

anpep marked this conversation as resolved.
Show resolved Hide resolved
return parser
}

Expand Down Expand Up @@ -362,7 +301,7 @@ func Run() error {
sug = "pebble help " + x.Name
}
}
return fmt.Errorf("unknown command %q, see '%s'.", sub, sug)
return fmt.Errorf("unknown command %q, see '%s'", sub, sug)
anpep marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand Down
24 changes: 12 additions & 12 deletions internals/cli/cmd_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
"io/ioutil"

"github.com/canonical/go-flags"

"github.com/canonical/pebble/client"
)

Expand All @@ -32,17 +31,22 @@ type cmdAdd struct {
} `positional-args:"yes"`
}

var addDescs = map[string]string{
"combine": `Combine the new layer with an existing layer that has the given label (default is to append)`,
}

var shortAddHelp = "Dynamically add a layer to the plan's layers"
var longAddHelp = `
func init() {
AddCommand(&CmdInfo{
Name: "add",
Summary: "Dynamically add a layer to the plan's layers",
Description: `
The add command reads the plan's layer YAML from the path specified and
appends a layer with the given label to the plan's layers. If --combine
is specified, combine the layer with an existing layer that has the given
label (or append if the label is not found).
`
`,
ArgsHelp: map[string]string{
"--combine": "Combine the new layer with an existing layer that has the given label (default is to append)",
},
Builder: func() flags.Commander { return &cmdAdd{} },
})
}

func (cmd *cmdAdd) Execute(args []string) error {
if len(args) > 0 {
Expand All @@ -65,7 +69,3 @@ func (cmd *cmdAdd) Execute(args []string) error {
cmd.Positional.Label, cmd.Positional.LayerPath)
return nil
}

func init() {
addCommand("add", shortAddHelp, longAddHelp, func() flags.Commander { return &cmdAdd{} }, addDescs, nil)
}
Loading