Add hook callback methods.

`BeforeHook()` and `AfterHook()` may be implemented on CLI nodes to
trigger hooks. Use the `Bind()` option to bind potential arguments.
This commit is contained in:
Alec Thomas
2018-07-04 22:29:47 +10:00
parent 1f1e9d0f0f
commit a13c5a0039
7 changed files with 195 additions and 184 deletions
+39 -20
View File
@@ -10,6 +10,7 @@
1. [Command handling](#command-handling)
1. [Switch on the command string](#switch-on-the-command-string)
1. [Attach a `Run(...) error` method to each command](#attach-a-run-error-method-to-each-command)
1. [BeforeHook\(\), AfterHook\(\) and the Bind\(\) option](#beforehook-afterhook-and-the-bind-option)
1. [Flags](#flags)
1. [Commands and sub-commands](#commands-and-sub-commands)
1. [Branching positional arguments](#branching-positional-arguments)
@@ -26,7 +27,7 @@
1. [`Resolver(...)` - support for default values from external sources](#resolver---support-for-default-values-from-external-sources)
1. [`*Mapper(...)` - customising how the command-line is mapped to Go values](#mapper---customising-how-the-command-line-is-mapped-to-go-values)
1. [`ConfigureHelp(HelpOptions)` and `Help(HelpFunc)` - customising help](#configurehelphelpoptions-and-helphelpfunc---customising-help)
1. [`Hook(&field, HookFunc)` - callback hooks to execute when the command-line is parsed](#hookfield-hookfunc---callback-hooks-to-execute-when-the-command-line-is-parsed)
1. [`Bindings(...)` - bind values for callback hooks anr Run\(\) methods](#bindings---bind-values-for-callback-hooks-anr-run-methods)
1. [Other options](#other-options)
<!-- /MarkdownTOC -->
@@ -165,6 +166,8 @@ A more robust approach is to break each command out into their own structs:
3. Call `kong.Kong.Parse()` to obtain a `kong.Context`.
4. Call `kong.Context.Run(params...)` to call the selected parsed command.
Note that `Run()` method arguments may also be provided by the `Bind(...)` option (see below).
There's a full example emulating part of the Docker CLI [here](https://github.com/alecthomas/kong/tree/master/_examples/docker).
eg.
@@ -207,6 +210,40 @@ func main() {
```
## BeforeHook(), AfterHook() and the Bind() option
If a node in the grammar has a `BeforeHook(...) error` and/or `AfterHook(...) error` method, those methods will
be called before validation/assignment and after validation/assignment, respectively.
The `--help` flag is implemented with a `BeforeHook`.
Arguments to hooks are provided via the `Bind(...)` option. `*Kong`, `*Context` and `*Path` are also bound.
eg.
```go
// A flag with a hook that, if triggered, will set the debug loggers output to stdout.
var debugFlag bool
func (d debugFlag) BeforeHook(logger *log.Logger) error {
logger.SetOutput(os.Stdout)
return nil
}
var cli struct {
Debug debugFlag `help:"Enable debug logging."`
}
func main() {
// Debug logger going to discard.
logger := log.New(ioutil.Discard, "", log.LstdFlags)
ctx := kong.Parse(&cli, kong.Bind(logger))
// ...
}
```
## Flags
Any [mapped](#mapper---customising-how-the-command-line-is-mapped-to-go-values) field in the command structure *not* tagged with `cmd` or `arg` will be a flag. Flags are optional by default.
@@ -450,26 +487,8 @@ The default help output is usually sufficient, but if not there are two solution
1. Use `ConfigureHelp(HelpOptions)` to configure how help is formatted (see [HelpOptions](https://godoc.org/github.com/alecthomas/kong#HelpOptions) for details).
2. Custom help can be wired into Kong via the `Help(HelpFunc)` option. The `HelpFunc` is passed a `Context`, which contains the parsed context for the current command-line. See the implementation of `PrintHelp` for an example.
### `Hook(&field, HookFunc)` - callback hooks to execute when the command-line is parsed
### `Bindings(...)` - bind values for callback hooks anr Run() methods
Hooks are callback functions that are bound to a node in the command-line and executed at parse time, before structural validation and assignment.
eg.
```go
app := kong.Must(&CLI, kong.Hook(&CLI.Debug, func(ctx *Context, path *Path) error {
log.SetLevel(DEBUG)
return nil
}))
```
Note: it is generally less verbose to use an imperative approach to building command-lines, eg.
```go
if CLI.Debug {
log.SetLevel(DEBUG)
}
```
But under some circumstances, hooks can be useful.
+55
View File
@@ -0,0 +1,55 @@
package kong
import (
"fmt"
"reflect"
)
type bindings map[reflect.Type]reflect.Value
func (b bindings) add(values ...interface{}) bindings {
for _, v := range values {
b[reflect.TypeOf(v)] = reflect.ValueOf(v)
}
return b
}
// Clone and add values.
func (b bindings) clone() bindings {
out := make(bindings, len(b))
for k, v := range b {
out[k] = v
}
return out
}
func getMethod(value reflect.Value, name string) reflect.Value {
method := value.MethodByName(name)
if !method.IsValid() {
if value.CanAddr() {
method = value.Addr().MethodByName(name)
}
}
return method
}
func callMethod(name string, v, f reflect.Value, bindings bindings) error {
in := []reflect.Value{}
t := f.Type()
if t.NumOut() != 1 || t.Out(0) != callbackReturnSignature {
return fmt.Errorf("return value of %T.%s() must be exactly \"error\"", v.Type(), name)
}
for i := 0; i < t.NumIn(); i++ {
pt := t.In(i)
if arg, ok := bindings[pt]; ok {
in = append(in, arg)
} else {
return fmt.Errorf("couldn't find binding of type %s for parameter %d of %T.%s(), use kong.Bind(%s)", pt, i, v.Type(), name, pt)
}
}
out := f.Call(in)
if out[0].IsNil() {
return nil
}
return out[0].Interface().(error)
}
+5 -65
View File
@@ -475,41 +475,20 @@ func (c *Context) parseFlag(flags []*Flag, match string) (err error) {
// The target Run() method must exist and have the type signature "Run(params...) error".
func (c *Context) Run(params ...interface{}) (err error) {
defer catch(&err)
expectedRunSignature, err := c.validateRun(c.Model.Node, nil)
if err != nil {
return err
}
if expectedRunSignature.NumIn() != len(params) {
return fmt.Errorf("expected %d params but received %d; does not match target Run() signature of %s",
expectedRunSignature.NumIn(), len(params), expectedRunSignature)
}
for i, param := range params {
if reflect.TypeOf(param) != expectedRunSignature.In(i) {
return fmt.Errorf("param %d is of type %s but should be of type %s to match target Run() signature of %s",
i, reflect.TypeOf(param), expectedRunSignature.In(i), expectedRunSignature)
}
}
node := c.Selected()
if node == nil {
return fmt.Errorf("no command selected")
}
method, err := getRunMethod(node.Target)
if err != nil {
return err
method := getMethod(node.Target, "Run")
if !method.IsValid() {
return fmt.Errorf("no Run() method on %s", node.Target)
}
_, err = c.Apply()
if err != nil {
return err
}
reflectedParams := []reflect.Value{}
for _, param := range params {
reflectedParams = append(reflectedParams, reflect.ValueOf(param))
}
result := method.Call(reflectedParams)
if result[0].IsNil() {
return nil
}
return result[0].Interface().(error)
binds := c.Kong.bindings.clone().add(params...).add(c)
return callMethod("Run", node.Target, method, binds)
}
// PrintUsage to Kong's stdout.
@@ -522,45 +501,6 @@ func (c *Context) PrintUsage(summary bool) error {
return nil
}
// Validate that all commands have Run() methods and that their signatures are the same.
func (c *Context) validateRun(node *Node, signature reflect.Type) (reflect.Type, error) {
if node.Leaf() {
method, err := getRunMethod(node.Target)
if err != nil {
return nil, err
}
if signature == nil {
signature = method.Type()
} else if signature != method.Type() {
return nil, fmt.Errorf("Run() methods are not consistent on %s, expected %s but got %s", node.Target.Type(), signature, method.Type())
}
if signature.NumOut() != 1 || signature.Out(0) != expectedRunReturnSignature {
return nil, fmt.Errorf("Run() method on %s should return (error)", node.Target.Type())
}
}
for _, child := range node.Children {
if childSignature, err := c.validateRun(child, signature); err != nil {
return nil, err
} else if signature == nil {
signature = childSignature
}
}
return signature, nil
}
func getRunMethod(value reflect.Value) (reflect.Value, error) {
method := value.MethodByName("Run")
if !method.IsValid() {
if value.CanAddr() {
method = value.Addr().MethodByName("Run")
}
if !method.IsValid() {
return method, fmt.Errorf("no Run() method on %s", value.Type())
}
}
return method, nil
}
func checkMissingFlags(flags []*Flag) error {
missing := []string{}
for _, flag := range flags {
+39 -32
View File
@@ -10,7 +10,7 @@ import (
)
var (
expectedRunReturnSignature = reflect.TypeOf((*error)(nil)).Elem()
callbackReturnSignature = reflect.TypeOf((*error)(nil)).Elem()
)
// Error reported by Kong.
@@ -42,7 +42,7 @@ type Kong struct {
Stdout io.Writer
Stderr io.Writer
before map[reflect.Value]HookFunc
bindings bindings
resolvers []ResolverFunc
registry *Registry
@@ -65,12 +65,14 @@ func New(grammar interface{}, options ...Option) (*Kong, error) {
Exit: os.Exit,
Stdout: os.Stdout,
Stderr: os.Stderr,
before: map[reflect.Value]HookFunc{},
registry: NewRegistry().RegisterDefaults(),
resolvers: []ResolverFunc{Envars()},
vars: map[string]string{},
bindings: bindings{},
}
options = append(options, Bind(k))
for _, option := range options {
if err := option.Apply(k); err != nil {
return nil, err
@@ -155,13 +157,26 @@ func mergeVars(base, extra map[string]string) map[string]string {
return out
}
type helpValue bool
func (h helpValue) BeforeHook(ctx *Context) error {
options := ctx.Kong.helpOptions
options.Summary = false
err := ctx.Kong.help(options, ctx)
if err != nil {
return err
}
ctx.Kong.Exit(1)
return nil
}
// Provide additional builtin flags, if any.
func (k *Kong) extraFlags() []*Flag {
if k.noDefaultHelp {
return nil
}
helpValue := false
value := reflect.ValueOf(&helpValue).Elem()
var helpTarget helpValue
value := reflect.ValueOf(&helpTarget).Elem()
helpFlag := &Flag{
Value: &Value{
Name: "help",
@@ -172,18 +187,7 @@ func (k *Kong) extraFlags() []*Flag {
},
}
helpFlag.Flag = helpFlag
hook := Hook(&helpValue, func(ctx *Context, path *Path) error {
options := k.helpOptions
options.Summary = false
err := k.help(options, ctx)
if err != nil {
return err
}
k.Exit(1)
return nil
})
k.helpFlag = helpFlag
_ = hook(k)
return []*Flag{helpFlag}
}
@@ -200,45 +204,48 @@ func (k *Kong) Parse(args []string) (ctx *Context, err error) {
if err != nil {
return nil, err
}
if err = k.applyHooks(ctx); err != nil {
return nil, &ParseError{error: err, Context: ctx}
}
if ctx.Error != nil {
return nil, &ParseError{error: ctx.Error, Context: ctx}
}
if err = k.applyHook(ctx, "BeforeHook"); err != nil {
return nil, &ParseError{error: err, Context: ctx}
}
if err = ctx.Validate(); err != nil {
return nil, &ParseError{error: err, Context: ctx}
}
if _, err = ctx.Apply(); err != nil {
return nil, &ParseError{error: err, Context: ctx}
}
if err = k.applyHook(ctx, "AfterHook"); err != nil {
return nil, &ParseError{error: err, Context: ctx}
}
return ctx, nil
}
func (k *Kong) applyHooks(ctx *Context) error {
func (k *Kong) applyHook(ctx *Context, name string) error {
for _, trace := range ctx.Path {
var key reflect.Value
var value reflect.Value
switch {
case trace.App != nil:
key = trace.App.Target
value = trace.App.Target
case trace.Argument != nil:
key = trace.Argument.Target
value = trace.Argument.Target
case trace.Command != nil:
key = trace.Command.Target
value = trace.Command.Target
case trace.Positional != nil:
key = trace.Positional.Target
value = trace.Positional.Target
case trace.Flag != nil:
key = trace.Flag.Value.Target
value = trace.Flag.Value.Target
default:
panic("unsupported Path")
}
if key.IsValid() {
key = key.Addr()
method := getMethod(value, name)
if !method.IsValid() {
continue
}
if hook := k.before[key]; hook != nil {
if err := hook(ctx, trace); err != nil {
return err
}
binds := k.bindings.clone().add(ctx, trace)
if err := callMethod(name, value, method, binds); err != nil {
return err
}
}
return nil
+47 -26
View File
@@ -355,43 +355,64 @@ func TestTraceErrorPartiallySucceeds(t *testing.T) {
require.Equal(t, "one", ctx.Command())
}
type hookContext struct {
cmd bool
values []string
}
type hookValue string
func (h *hookValue) BeforeHook(ctx *hookContext) error {
ctx.values = append(ctx.values, "before:"+string(*h))
return nil
}
func (h *hookValue) AfterHook(ctx *hookContext) error {
ctx.values = append(ctx.values, "after:"+string(*h))
return nil
}
type hookCmd struct {
Two hookValue `kong:"arg,optional"`
Three hookValue
}
func (h *hookCmd) BeforeHook(ctx *hookContext) error {
ctx.cmd = true
return nil
}
func (h *hookCmd) AfterHook(ctx *hookContext) error {
ctx.cmd = true
return nil
}
func TestHooks(t *testing.T) {
var cli struct {
One struct {
Two string `kong:"arg,optional"`
Three string
} `kong:"cmd"`
}
type values struct {
one bool
two string
three string
}
hooked := values{}
var tests = []struct {
name string
input string
values values
values hookContext
}{
{"Command", "one", values{true, "", ""}},
{"Arg", "one two", values{true, "two", ""}},
{"Flag", "one --three=three", values{true, "", "three"}},
{"ArgAndFlag", "one two --three=three", values{true, "two", "three"}},
{"Command", "one", hookContext{true, nil}},
{"Arg", "one two", hookContext{true, []string{"before:", "after:two"}}},
{"Flag", "one --three=THREE", hookContext{true, []string{"before:", "after:THREE"}}},
{"ArgAndFlag", "one two --three=THREE", hookContext{true, []string{"before:", "before:", "after:two", "after:THREE"}}},
}
setOne := func(ctx *kong.Context, path *kong.Path) error { hooked.one = true; return nil }
setTwo := func(ctx *kong.Context, path *kong.Path) error { hooked.two = ctx.Value(path).String(); return nil }
setThree := func(ctx *kong.Context, path *kong.Path) error { hooked.three = ctx.Value(path).String(); return nil }
p := mustNew(t, &cli,
kong.Hook(&cli.One, setOne),
kong.Hook(&cli.One.Two, setTwo),
kong.Hook(&cli.One.Three, setThree))
var cli struct {
One hookCmd `cmd:""`
}
ctx := &hookContext{}
p := mustNew(t, &cli, kong.Bind(ctx))
for _, test := range tests {
hooked = values{}
*ctx = hookContext{}
cli.One = hookCmd{}
t.Run(test.name, func(t *testing.T) {
_, err := p.Parse(strings.Split(test.input, " "))
require.NoError(t, err)
require.Equal(t, test.values, hooked)
require.Equal(t, &test.values, ctx)
})
}
}
+10 -14
View File
@@ -109,24 +109,20 @@ func Writers(stdout, stderr io.Writer) OptionFunc {
}
}
// HookFunc is a callback tied to a field of the grammar, called before a value is applied.
// Bind binds values for hooks and Run() function arguments.
//
// "ctx" is the current parse Context, "path" is the Path entry corresponding to the hooked value.
type HookFunc func(ctx *Context, path *Path) error
// Hook to apply before a command, flag or positional argument is encountered.
// Any arguments passed will be available to the receiving hook functions, but may be omitted. Additionally, *Kong and
// the current *Context will also be made available.
//
// "ptr" is a pointer to a field of the grammar.
// There are two hook points:
//
// Note that the hook will be called once for each time the corresponding node is encountered. This means that if a flag
// is passed twice, its hook will be called twice.
func Hook(ptr interface{}, hook HookFunc) OptionFunc {
key := reflect.ValueOf(ptr)
if key.Kind() != reflect.Ptr {
panic("expected a pointer")
}
// BeforeHook(...) error
// AfterHook(...) error
//
// Called before validation/assignment, and immediately after validation/assignment, respectively.
func Bind(args ...interface{}) OptionFunc {
return func(k *Kong) error {
k.before[key] = hook
k.bindings.add(args...)
return nil
}
}
-27
View File
@@ -125,33 +125,6 @@ func TestJSONBasic(t *testing.T) {
require.True(t, cli.Bool)
}
func TestResolvedValueTriggersHooks(t *testing.T) {
var cli struct {
Int int
}
resolver := func(context *kong.Context, parent *kong.Path, flag *kong.Flag) (string, error) {
if flag.Name == "int" {
return "1", nil
}
return "", nil
}
hooked := 0
p := mustNew(t, &cli, kong.Resolver(resolver), kong.Hook(&cli.Int, func(ctx *kong.Context, path *kong.Path) error {
hooked++
return nil
}))
_, err := p.Parse(nil)
require.NoError(t, err)
require.Equal(t, 1, cli.Int)
require.Equal(t, 1, hooked)
hooked = 0
_, err = p.Parse([]string{"--int=2"})
require.NoError(t, err)
require.Equal(t, 2, cli.Int)
require.Equal(t, 1, hooked)
}
type testUppercaseMapper struct{}
func (testUppercaseMapper) Decode(ctx *kong.DecodeContext, target reflect.Value) error {