Add configuration loading + docs + linter fixes.

This commit is contained in:
Alec Thomas
2018-06-13 10:33:22 +10:00
parent a5c97373ba
commit 232faad0a0
15 changed files with 218 additions and 17 deletions
+5
View File
@@ -12,8 +12,13 @@ jobs:
command: | command: |
go get -v github.com/jstemmer/go-junit-report go get -v github.com/jstemmer/go-junit-report
go get -v -t -d ./... go get -v -t -d ./...
curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | bash -s latest
mkdir ~/report mkdir ~/report
when: always when: always
- run:
name: Lint
command: |
./bin/golangci-lint run
- run: - run:
name: Test name: Test
command: | command: |
+33
View File
@@ -0,0 +1,33 @@
run:
tests: true
output:
print-issued-lines: false
linters:
enable-all: true
disable:
- maligned
linters-settings:
govet:
check-shadowing: true
gocyclo:
min-complexity: 10
dupl:
threshold: 100
goconst:
min-len: 5
min-occurrences: 3
gocyclo:
min-complexity: 20
issues:
max-per-linter: 0
max-same: 0
exclude-use-default: false
exclude:
- '^(G104|G204):'
# Very commonly not checked.
- 'Error return value of .(.*\.Help|.*\.MarkFlagRequired|(os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked'
- 'exported method (.*\.MarshalJSON|.*\.UnmarshalJSON) should have comment or be unexported'
+22 -2
View File
@@ -10,6 +10,8 @@
1. [Commands and sub-commands](#commands-and-sub-commands) 1. [Commands and sub-commands](#commands-and-sub-commands)
1. [Supported tags](#supported-tags) 1. [Supported tags](#supported-tags)
1. [Configuring Kong](#configuring-kong) 1. [Configuring Kong](#configuring-kong)
1. [`Configuration(loader, paths...)` - load defaults from configuration files](#configurationloader-paths---load-defaults-from-configuration-files)
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. [`*Mapper(...)` - customising how the command-line is mapped to Go values](#mapper---customising-how-the-command-line-is-mapped-to-go-values)
1. [`Help(HelpFunc)` - customising help](#helphelpfunc---customising-help) 1. [`Help(HelpFunc)` - customising help](#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. [`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)
@@ -159,7 +161,25 @@ Both can coexist with standard Tag parsing.
## Configuring Kong ## Configuring Kong
Each Kong parser can be configured via functional options passed to `New(cli interface{}, options...Option)`. The full set of options can be found in `options.go`. Each Kong parser can be configured via functional options passed to `New(cli interface{}, options...Option)`.
The full set of options can be found in [options.go](https://github.com/alecthomas/kong/blob/master/options.go).
### `Configuration(loader, paths...)` - load defaults from configuration files
This option provides Kong with support for loading defaults from a set of configuration files. Each file is opened, if possible, and the loader called to create a resolver for that file.
eg.
```go
kong.Parse(&cli, kong.Configuration(kong.JSON, "/etc/myapp.json", "~/.myapp.json"))
```
### `Resolver(...)` - support for default values from external sources
Resolvers are Kong's extension point for providing default values from external sources. As an example, support for environment variables via the `env` tag is provided by a resolver. There's also a builtin resolver for JSON configuration files.
Example resolvers can be found in [resolver.go](https://github.com/alecthomas/kong/blob/master/resolver.go).
### `*Mapper(...)` - customising how the command-line is mapped to Go values ### `*Mapper(...)` - customising how the command-line is mapped to Go values
@@ -176,7 +196,7 @@ type Mapper interface {
} }
``` ```
All builtin Go types (as well as a bunch of useful stdlib types like `time.Time`) have mapperss registered by default. Mappers for custom types can be added using `kong.??Mapper(...)` options. Mappers are applied to fields in four ways: All builtin Go types (as well as a bunch of useful stdlib types like `time.Time`) have mappers registered by default. Mappers for custom types can be added using `kong.??Mapper(...)` options. Mappers are applied to fields in four ways:
1. `NamedMapper(string, Mapper)` and using the tag key `type:"<name>"`. 1. `NamedMapper(string, Mapper)` and using the tag key `type:"<name>"`.
2. `KindMapper(reflect.Kind, Mapper)`. 2. `KindMapper(reflect.Kind, Mapper)`.
+30
View File
@@ -0,0 +1,30 @@
// Package kong aims to support arbitrarily complex command-line structures with as little developer effort as possible.
//
// Here's an example:
//
// shell rm [-f] [-r] <paths> ...
// shell ls [<paths> ...]
//
// This can be represented by the following command-line structure:
//
// package main
//
// import "github.com/alecthomas/kong"
//
// var CLI struct {
// Rm struct {
// Force bool `short:"f" help:"Force removal."`
// Recursive bool `short:"r" help:"Recursively remove files."`
//
// Paths []string `arg help:"Paths to remove." type:"path"`
// } `cmd help:"Remove files."`
//
// Ls struct {
// Paths []string `arg optional help:"Paths to list." type:"path"`
// } `cmd help:"List paths."`
// }
//
// func main() {
// kong.Parse(&CLI)
// }
package kong
+3
View File
@@ -19,6 +19,7 @@ func Parse(cli interface{}, options ...Option) string {
return cmd return cmd
} }
// FatalIfErrorf terminates with an error message if err != nil.
func FatalIfErrorf(err error, args ...interface{}) { func FatalIfErrorf(err error, args ...interface{}) {
if App == nil { if App == nil {
panic("call kong.Parse() before using kong.FatalIfErrorf()") panic("call kong.Parse() before using kong.FatalIfErrorf()")
@@ -26,6 +27,7 @@ func FatalIfErrorf(err error, args ...interface{}) {
App.FatalIfErrorf(err, args...) App.FatalIfErrorf(err, args...)
} }
// Errorf writes a message to Kong.Stderr with the application name prefixed.
func Errorf(format string, args ...interface{}) { func Errorf(format string, args ...interface{}) {
if App == nil { if App == nil {
panic("call kong.Parse() before using kong.Errorf()") panic("call kong.Parse() before using kong.Errorf()")
@@ -33,6 +35,7 @@ func Errorf(format string, args ...interface{}) {
App.Errorf(format, args...) App.Errorf(format, args...)
} }
// Printf writes a message to Kong.Stdout with the application name prefixed.
func Printf(format string, args ...interface{}) { func Printf(format string, args ...interface{}) {
if App == nil { if App == nil {
panic("call kong.Parse() before using kong.Printf()") panic("call kong.Parse() before using kong.Printf()")
+2 -2
View File
@@ -26,9 +26,9 @@ func guessWidth(w io.Writer) int {
if _, _, err := syscall.Syscall6( if _, _, err := syscall.Syscall6(
syscall.SYS_IOCTL, syscall.SYS_IOCTL,
uintptr(fd), uintptr(fd), // nolint: unconvert
uintptr(syscall.TIOCGWINSZ), uintptr(syscall.TIOCGWINSZ),
uintptr(unsafe.Pointer(&dimensions)), uintptr(unsafe.Pointer(&dimensions)), // nolint: gas
0, 0, 0, 0, 0, 0,
); err == 0 { ); err == 0 {
return int(dimensions[1]) return int(dimensions[1])
+2 -2
View File
@@ -58,7 +58,7 @@ func New(grammar interface{}, options ...Option) (*Kong, error) {
before: map[reflect.Value]HookFunc{}, before: map[reflect.Value]HookFunc{},
registry: NewRegistry().RegisterDefaults(), registry: NewRegistry().RegisterDefaults(),
help: PrintHelp, help: PrintHelp,
resolvers: []ResolverFunc{EnvResolver()}, resolvers: []ResolverFunc{Envars()},
} }
for _, option := range options { for _, option := range options {
@@ -123,7 +123,7 @@ func (k *Kong) Parse(args []string) (command string, err error) {
if err != nil { if err != nil {
return "", err return "", err
} }
if err := k.applyHooks(ctx); err != nil { if err = k.applyHooks(ctx); err != nil {
return "", err return "", err
} }
if ctx.Error != nil { if ctx.Error != nil {
+2
View File
@@ -112,6 +112,7 @@ func TestFlagSliceWithSeparator(t *testing.T) {
} }
func TestArgSlice(t *testing.T) { func TestArgSlice(t *testing.T) {
// nolint: govet
var cli struct { var cli struct {
Slice []int `arg` Slice []int `arg`
Flag bool Flag bool
@@ -124,6 +125,7 @@ func TestArgSlice(t *testing.T) {
} }
func TestArgSliceWithSeparator(t *testing.T) { func TestArgSliceWithSeparator(t *testing.T) {
// nolint: govet
var cli struct { var cli struct {
Slice []string `arg` Slice []string `arg`
Flag bool Flag bool
+10
View File
@@ -7,23 +7,29 @@ import (
"strings" "strings"
) )
// Application is the root of the Kong model.
type Application struct { type Application struct {
Node Node
HelpFlag *Flag HelpFlag *Flag
} }
// Argument represents a branching positional argument.
type Argument = Node type Argument = Node
// Command represents a command in the CLI.
type Command = Node type Command = Node
// NodeType is an enum representing the type of a Node.
type NodeType int type NodeType int
// Node type enumerations.
const ( const (
ApplicationNode NodeType = iota ApplicationNode NodeType = iota
CommandNode CommandNode
ArgumentNode ArgumentNode
) )
// Node is a branch in the CLI. ie. a command or positional argument.
type Node struct { type Node struct {
Type NodeType Type NodeType
Parent *Node Parent *Node
@@ -37,6 +43,7 @@ type Node struct {
Argument *Value // Populated when Type is ArgumentNode. Argument *Value // Populated when Type is ArgumentNode.
} }
// AllFlags returns flags from all ancestor branches encountered.
func (n *Node) AllFlags() (out [][]*Flag) { func (n *Node) AllFlags() (out [][]*Flag) {
if n.Parent != nil { if n.Parent != nil {
out = append(out, n.Parent.AllFlags()...) out = append(out, n.Parent.AllFlags()...)
@@ -186,6 +193,9 @@ func (v *Value) Apply(value reflect.Value) {
v.Set = true v.Set = true
} }
// Reset this value to its default, either the zero value or the parsed result of its "default" tag.
//
// Does not include resolvers.
func (v *Value) Reset() error { func (v *Value) Reset() error {
v.Value.Set(reflect.Zero(v.Value.Type())) v.Value.Set(reflect.Zero(v.Value.Type()))
if v.Default != "" { if v.Default != "" {
+50 -1
View File
@@ -2,10 +2,14 @@ package kong
import ( import (
"io" "io"
"os"
"os/user"
"path/filepath"
"reflect" "reflect"
"strings"
) )
// Options apply optional changes to the Kong application. // An Option applies optional changes to the Kong application.
type Option func(k *Kong) type Option func(k *Kong)
// ExitFunction overrides the function used to terminate. This is useful for testing or interactive use. // ExitFunction overrides the function used to terminate. This is useful for testing or interactive use.
@@ -110,3 +114,48 @@ func Resolver(resolvers ...ResolverFunc) Option {
k.resolvers = append(k.resolvers, resolvers...) k.resolvers = append(k.resolvers, resolvers...)
} }
} }
// ConfigurationFunc is a function that builds a resolver from a file.
type ConfigurationFunc func(r io.Reader) (ResolverFunc, error)
// Configuration provides Kong with support for loading defaults from a set of configuration files.
//
// Paths will be opened in order, and "loader" will be used to provide a ResolverFunc which is registered with Kong.
//
// Note: The JSON function is a ConfigurationFunc.
//
// ~ expansion will occur on the provided paths.
func Configuration(loader ConfigurationFunc, paths ...string) Option {
return func(k *Kong) {
for _, path := range paths {
path = expandPath(path)
r, err := os.Open(path) // nolint: gas
if err != nil {
continue
}
resolver, err := loader(r)
if err == nil {
k.resolvers = append(k.resolvers, resolver)
}
_ = r.Close()
}
}
}
func expandPath(path string) string {
if filepath.IsAbs(path) {
return path
}
if strings.HasPrefix(path, "~/") {
user, err := user.Current()
if err != nil {
return path
}
return filepath.Join(user.HomeDir, path[2:])
}
abspath, err := filepath.Abs(path)
if err != nil {
return path
}
return abspath
}
+31
View File
@@ -1,6 +1,9 @@
package kong package kong
import ( import (
"encoding/json"
"io/ioutil"
"os"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -16,3 +19,31 @@ func TestOptions(t *testing.T) {
require.Nil(t, p.Stderr) require.Nil(t, p.Stderr)
require.Nil(t, p.Exit) require.Nil(t, p.Exit)
} }
func TestConfigLoading(t *testing.T) {
first, err := ioutil.TempFile("", "")
require.NoError(t, err)
defer first.Close()
defer os.Remove(first.Name())
second, err := ioutil.TempFile("", "")
require.NoError(t, err)
defer second.Close()
defer os.Remove(second.Name())
var cli struct {
Flag string `json:"flag,omitempty"`
}
cli.Flag = "first"
err = json.NewEncoder(first).Encode(&cli)
require.NoError(t, err)
cli.Flag = ""
err = json.NewEncoder(second).Encode(&cli)
require.NoError(t, err)
p := mustNew(t, &cli, Configuration(JSON, first.Name(), second.Name()))
_, err = p.Parse(nil)
require.NoError(t, err)
require.Equal(t, "first", cli.Flag)
}
+4 -4
View File
@@ -11,10 +11,10 @@ import (
// ResolverFunc resolves a Flag value from an external source. // ResolverFunc resolves a Flag value from an external source.
type ResolverFunc func(context *Context, parent *Path, flag *Flag) (string, error) type ResolverFunc func(context *Context, parent *Path, flag *Flag) (string, error)
// JSONResolver returns a Resolver that retrieves values from a JSON source. // JSON returns a Resolver that retrieves values from a JSON source.
// //
// Hyphens in flag names are replaced with underscores. // Hyphens in flag names are replaced with underscores.
func JSONResolver(r io.Reader) (ResolverFunc, error) { func JSON(r io.Reader) (ResolverFunc, error) {
values := map[string]interface{}{} values := map[string]interface{}{}
err := json.NewDecoder(r).Decode(&values) err := json.NewDecoder(r).Decode(&values)
if err != nil { if err != nil {
@@ -61,10 +61,10 @@ func jsonDecodeValue(sep rune, value interface{}) (string, error) {
return "", fmt.Errorf("unsupported JSON value %v (of type %T)", value, value) return "", fmt.Errorf("unsupported JSON value %v (of type %T)", value, value)
} }
// EnvResolver resolves flag values using the `env:"<name>"` tag. It ignores flags without this tag. // Envars resolves flag values using the `env:"<name>"` tag. It ignores flags without this tag.
// //
// This resolver is installed by default. // This resolver is installed by default.
func EnvResolver() ResolverFunc { func Envars() ResolverFunc {
return func(context *Context, parent *Path, flag *Flag) (string, error) { return func(context *Context, parent *Path, flag *Flag) (string, error) {
if flag.Tag.Env == "" { if flag.Tag.Env == "" {
return "", nil return "", nil
+7 -6
View File
@@ -30,7 +30,7 @@ func newEnvParser(t *testing.T, cli interface{}, env envMap) (*Kong, func()) {
return parser, restoreEnv return parser, restoreEnv
} }
func TestEnvResolverFlagBasic(t *testing.T) { func TestEnvarsFlagBasic(t *testing.T) {
var cli struct { var cli struct {
String string `env:"KONG_STRING"` String string `env:"KONG_STRING"`
Slice []int `env:"KONG_SLICE"` Slice []int `env:"KONG_SLICE"`
@@ -47,7 +47,7 @@ func TestEnvResolverFlagBasic(t *testing.T) {
require.Equal(t, []int{5, 2, 9}, cli.Slice) require.Equal(t, []int{5, 2, 9}, cli.Slice)
} }
func TestEnvResolverFlagOverride(t *testing.T) { func TestEnvarsFlagOverride(t *testing.T) {
var cli struct { var cli struct {
Flag string `env:"KONG_FLAG"` Flag string `env:"KONG_FLAG"`
} }
@@ -59,7 +59,7 @@ func TestEnvResolverFlagOverride(t *testing.T) {
require.Equal(t, "hello", cli.Flag) require.Equal(t, "hello", cli.Flag)
} }
func TestEnvResolverOnlyPopulateUsedBranches(t *testing.T) { func TestEnvarsOnlyPopulateUsedBranches(t *testing.T) {
// nolint // nolint
var cli struct { var cli struct {
UnvisitedArg struct { UnvisitedArg struct {
@@ -84,7 +84,7 @@ func TestEnvResolverOnlyPopulateUsedBranches(t *testing.T) {
require.Equal(t, 0, cli.UnvisitedCmd.Int) require.Equal(t, 0, cli.UnvisitedCmd.Int)
} }
func TestEnvResolverTag(t *testing.T) { func TestEnvarsTag(t *testing.T) {
var cli struct { var cli struct {
Slice []int `env:"KONG_NUMBERS"` Slice []int `env:"KONG_NUMBERS"`
} }
@@ -96,7 +96,7 @@ func TestEnvResolverTag(t *testing.T) {
require.Equal(t, []int{5, 2, 9}, cli.Slice) require.Equal(t, []int{5, 2, 9}, cli.Slice)
} }
func TestJSONResolverBasic(t *testing.T) { func TestJSONBasic(t *testing.T) {
var cli struct { var cli struct {
String string String string
Slice []int Slice []int
@@ -111,7 +111,7 @@ func TestJSONResolverBasic(t *testing.T) {
"slice_with_commas": ["a,b", "c"] "slice_with_commas": ["a,b", "c"]
}` }`
r, err := JSONResolver(strings.NewReader(json)) r, err := JSON(strings.NewReader(json))
require.NoError(t, err) require.NoError(t, err)
parser := mustNew(t, &cli, Resolver(r)) parser := mustNew(t, &cli, Resolver(r))
@@ -219,6 +219,7 @@ func TestLastResolverWins(t *testing.T) {
} }
func TestResolverSatisfiesRequired(t *testing.T) { func TestResolverSatisfiesRequired(t *testing.T) {
// nolint: govet
var cli struct { var cli struct {
Int int `required` Int int `required`
} }
+10
View File
@@ -21,6 +21,7 @@ const (
PositionalArgumentToken // <arg> PositionalArgumentToken // <arg>
) )
// Token created by Scanner.
type Token struct { type Token struct {
Value string Value string
Type TokenType Type TokenType
@@ -42,10 +43,12 @@ func (t Token) String() string {
} }
} }
// IsEOL returns true if this Token is past the end of the line.
func (t Token) IsEOL() bool { func (t Token) IsEOL() bool {
return t.Type == EOLToken return t.Type == EOLToken
} }
// IsAny returns true if the token's type is any of those provided.
func (t Token) IsAny(types ...TokenType) bool { func (t Token) IsAny(types ...TokenType) bool {
for _, typ := range types { for _, typ := range types {
if t.Type == typ { if t.Type == typ {
@@ -75,6 +78,7 @@ type Scanner struct {
args []Token args []Token
} }
// Scan creates a new Scanner from args with untyped tokens.
func Scan(args ...string) *Scanner { func Scan(args ...string) *Scanner {
s := &Scanner{} s := &Scanner{}
for _, arg := range args { for _, arg := range args {
@@ -83,10 +87,12 @@ func Scan(args ...string) *Scanner {
return s return s
} }
// Len returns the number of input arguments.
func (s *Scanner) Len() int { func (s *Scanner) Len() int {
return len(s.args) return len(s.args)
} }
// Pop the front token off the Scanner.
func (s *Scanner) Pop() Token { func (s *Scanner) Pop() Token {
if len(s.args) == 0 { if len(s.args) == 0 {
return Token{Type: EOLToken} return Token{Type: EOLToken}
@@ -123,6 +129,7 @@ func (s *Scanner) PopUntil(predicate func(Token) bool) (values []string) {
return return
} }
// Peek at the next Token or return an EOLToken.
func (s *Scanner) Peek() Token { func (s *Scanner) Peek() Token {
if len(s.args) == 0 { if len(s.args) == 0 {
return Token{Type: EOLToken} return Token{Type: EOLToken}
@@ -130,16 +137,19 @@ func (s *Scanner) Peek() Token {
return s.args[0] return s.args[0]
} }
// Push an untyped Token onto the front of the Scanner.
func (s *Scanner) Push(arg string) *Scanner { func (s *Scanner) Push(arg string) *Scanner {
s.PushToken(Token{Value: arg}) s.PushToken(Token{Value: arg})
return s return s
} }
// PushTyped pushes a typed token onto the front of the Scanner.
func (s *Scanner) PushTyped(arg string, typ TokenType) *Scanner { func (s *Scanner) PushTyped(arg string, typ TokenType) *Scanner {
s.PushToken(Token{Value: arg, Type: typ}) s.PushToken(Token{Value: arg, Type: typ})
return s return s
} }
// PushToken pushes a preconstructed Token onto the front of the Scanner.
func (s *Scanner) PushToken(token Token) *Scanner { func (s *Scanner) PushToken(token Token) *Scanner {
s.args = append([]Token{token}, s.args...) s.args = append([]Token{token}, s.args...)
return s return s
+7
View File
@@ -8,6 +8,7 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// Tag represents the parsed state of Kong tags in a struct field tag.
type Tag struct { type Tag struct {
Cmd bool Cmd bool
Arg bool Arg bool
@@ -145,28 +146,34 @@ func parseTag(fv reflect.Value, ft reflect.StructField) *Tag {
return t return t
} }
// Has returns true if the tag contained the given key.
func (t *Tag) Has(k string) bool { func (t *Tag) Has(k string) bool {
_, ok := t.items[k] _, ok := t.items[k]
return ok return ok
} }
// Get returns the value of the given tag.
func (t *Tag) Get(k string) (string, bool) { func (t *Tag) Get(k string) (string, bool) {
s, ok := t.items[k] s, ok := t.items[k]
return s, ok return s, ok
} }
// GetBool returns true if the given tag looks like a boolean truth string.
func (t *Tag) GetBool(k string) (bool, error) { func (t *Tag) GetBool(k string) (bool, error) {
return strconv.ParseBool(t.items[k]) return strconv.ParseBool(t.items[k])
} }
// GetFloat parses the given tag as a float64.
func (t *Tag) GetFloat(k string) (float64, error) { func (t *Tag) GetFloat(k string) (float64, error) {
return strconv.ParseFloat(t.items[k], 64) return strconv.ParseFloat(t.items[k], 64)
} }
// GetInt parses the given tag as an int64.
func (t *Tag) GetInt(k string) (int64, error) { func (t *Tag) GetInt(k string) (int64, error) {
return strconv.ParseInt(t.items[k], 10, 64) return strconv.ParseInt(t.items[k], 10, 64)
} }
// GetRune parses the given tag as a rune.
func (t *Tag) GetRune(k string) (rune, error) { func (t *Tag) GetRune(k string) (rune, error) {
r, _ := utf8.DecodeRuneInString(t.items[k]) r, _ := utf8.DecodeRuneInString(t.items[k])
if r == utf8.RuneError { if r == utf8.RuneError {