Skip to content

Commit

Permalink
cli: public commands API (#232)
Browse files Browse the repository at this point in the history
  • Loading branch information
anpep authored Aug 1, 2023
1 parent e214581 commit 37a6155
Show file tree
Hide file tree
Showing 29 changed files with 536 additions and 483 deletions.
263 changes: 108 additions & 155 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 @@ -48,70 +49,52 @@ 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 {
// 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

// 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)
}
// Builder is a function that creates a new instance of the command
// struct containing an Execute(args []string) implementation.
Builder func() flags.Commander

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

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

// 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 {
info := &cmdInfo{
name: name,
shortHelp: shortHelp,
longHelp: longHelp,
builder: builder,
optDescs: optDescs,
argDescs: argDescs,
}
commands = append(commands, info)
return info
// 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",
// "<change-id>": "named positional argument"
// }
ArgsHelp map[string]string

// Whether to pass all arguments after the first non-option as remaining
// command line arguments. This is equivalent to strict POSIX processing.
PassAfterNonOption bool

// When set, the command will be a subcommand of the `debug` command.
Debug bool
}

// 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 {
info := &cmdInfo{
name: name,
shortHelp: shortHelp,
longHelp: longHelp,
builder: builder,
optDescs: optDescs,
argDescs: argDescs,
}
debugCommands = append(debugCommands, info)
return info
}
// commands holds information about all the regular Pebble commands.
var commands []*CmdInfo

type parserSetter interface {
setParser(*flags.Parser)
// AddCommand adds a command to the top-level parser.
func AddCommand(info *CmdInfo) {
commands = append(commands, info)
}

func lintDesc(cmdName, optName, desc, origDesc string) {
Expand Down Expand Up @@ -168,134 +151,104 @@ func (ch *clientMixin) setClient(cli *client.Client) {
ch.client = cli
}

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

type defaultOptions struct {
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
defaultOpts := defaultOptions{
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(&defaultOpts, 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 {
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)

// Add all regular commands
// Regular expressions for positional and flag arguments
positionalRegexp := regexp.MustCompile(`^<\w+(-\w+)>$`)
flagRegexp := regexp.MustCompile(`^-\w|--\w+(-\w+)*$`)

// Create debug command
debugCmd, err := parser.AddCommand("debug", cmdDebugSummary, cmdDebugDescription, &cmdDebug{})
debugCmd.Hidden = true
if err != nil {
logger.Panicf("internal error: cannot add command %q: %v", "debug", err)
}

// Add all 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)
if err != nil {
logger.Panicf("cannot add command %q: %v", c.name, err)
}
cmd.Hidden = c.hidden
if c.alias != "" {
cmd.Aliases = append(cmd.Aliases, c.alias)
var target *flags.Command
if c.Debug {
target = debugCmd
} else {
target = parser.Command
}

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))
cmd, err := target.AddCommand(c.Name, c.Summary, strings.TrimSpace(c.Description), obj)
if err != nil {
logger.Panicf("internal error: cannot add command %q: %v", c.Name, err)
}
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

// Extract help for flags and positional arguments from ArgsHelp
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("internal error: invalid help specifier from %q: %q", c.Name, specifier)
}
}

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 {
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("internal error: wrong number of flag descriptions for %q: expected %d, got %d", c.Name, len(opts), len(flagHelp))
}
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("internal error: %q missing description for %q", c.Name, opt)
}
}

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
}
}
return parser
Expand Down Expand Up @@ -361,7 +314,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)
}
}

Expand Down
32 changes: 18 additions & 14 deletions internals/cli/cmd_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import (
"github.com/canonical/pebble/client"
)

const cmdAddSummary = "Dynamically add a layer to the plan's layers"
const cmdAddDescription = `
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).
`

type cmdAdd struct {
clientMixin
Combine bool `long:"combine"`
Expand All @@ -32,18 +40,18 @@ 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)`,
func init() {
AddCommand(&CmdInfo{
Name: "add",
Summary: cmdAddSummary,
Description: cmdAddDescription,
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{} },
})
}

var shortAddHelp = "Dynamically add a layer to the plan's layers"
var longAddHelp = `
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).
`

func (cmd *cmdAdd) Execute(args []string) error {
if len(args) > 0 {
return ErrExtraArgs
Expand All @@ -65,7 +73,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

0 comments on commit 37a6155

Please sign in to comment.