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 17 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
272 changes: 116 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,55 @@ 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

// 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 {
anpep marked this conversation as resolved.
Show resolved Hide resolved
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",
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

// 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 {
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
}
// commands holds information about all the regular Pebble commands.
var commands []*CmdInfo

type parserSetter interface {
setParser(*flags.Parser)
// debugCommands holds information about all the subcommands of the `pebble debug` command.
var debugCommands []*CmdInfo

// AddCommand adds a command to the top-level parser.
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
}

func lintDesc(cmdName, optName, desc, origDesc string) {
Expand Down Expand Up @@ -169,134 +155,108 @@ 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
anpep marked this conversation as resolved.
Show resolved Hide resolved
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 {
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)

// Add all regular commands
for _, c := range commands {
obj := c.builder()
// 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
ncmds := len(commands)
allCmds := make([]*CmdInfo, ncmds, ncmds+len(debugCommands))
copy(allCmds, commands)
copy(allCmds[ncmds:], debugCommands)
for _, c := range allCmds {
anpep marked this conversation as resolved.
Show resolved Hide resolved
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 {
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("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 @@ -362,7 +322,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
33 changes: 18 additions & 15 deletions internals/cli/cmd_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,17 @@ import (
"io/ioutil"

"github.com/canonical/go-flags"

"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 +39,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 +72,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