Skip to content

Commit

Permalink
feat: support multiple env variables (#349)
Browse files Browse the repository at this point in the history
  • Loading branch information
titusjaka authored Jan 31, 2023
1 parent 37e8014 commit 9610ed6
Show file tree
Hide file tree
Showing 11 changed files with 149 additions and 35 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,10 +522,10 @@ Tags can be in two forms:
Both can coexist with standard Tag parsing.

| Tag | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `cmd:""` | If present, struct is a command. |
| `arg:""` | If present, field is an argument. Required by default. |
| `env:"X"` | Specify envar to use for default value. |
| `env:"X,Y,..."` | Specify envars to use for default value. The envs are resolved in the declared order. The first value found is used. |
| `name:"X"` | Long name, for overriding field name. |
| `help:"X"` | Help text. |
| `type:"X"` | Specify [named types](#custom-named-decoders) to use. |
Expand Down
8 changes: 5 additions & 3 deletions build.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ MAIN:
name = tag.Prefix + name
}

if tag.Env != "" {
tag.Env = tag.EnvPrefix + tag.Env
if len(tag.Envs) != 0 {
for i := range tag.Envs {
tag.Envs[i] = tag.EnvPrefix + tag.Envs[i]
}
}

// Nested structs are either commands or args, unless they implement the Mapper interface.
Expand Down Expand Up @@ -304,7 +306,7 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv
Value: value,
Short: tag.Short,
PlaceHolder: tag.PlaceHolder,
Env: tag.Env,
Envs: tag.Envs,
Group: buildGroupForKey(k, tag.Group),
Xor: tag.Xor,
Hidden: tag.Hidden,
Expand Down
22 changes: 15 additions & 7 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,16 @@ func (c *Context) Validate() error { // nolint: gocyclo
err := Visit(c.Model, func(node Visitable, next Next) error {
switch node := node.(type) {
case *Value:
_, ok := os.LookupEnv(node.Tag.Env)
if node.Enum != "" && (!node.Required || node.HasDefault || (node.Tag.Env != "" && ok)) {
ok := atLeastOneEnvSet(node.Tag.Envs)
if node.Enum != "" && (!node.Required || node.HasDefault || (len(node.Tag.Envs) != 0 && ok)) {
if err := checkEnum(node, node.Target); err != nil {
return err
}
}

case *Flag:
_, ok := os.LookupEnv(node.Tag.Env)
if node.Enum != "" && (!node.Required || node.HasDefault || (node.Tag.Env != "" && ok)) {
ok := atLeastOneEnvSet(node.Tag.Envs)
if node.Enum != "" && (!node.Required || node.HasDefault || (len(node.Tag.Envs) != 0 && ok)) {
if err := checkEnum(node.Value, node.Target); err != nil {
return err
}
Expand Down Expand Up @@ -890,9 +890,8 @@ func checkMissingPositionals(positional int, values []*Value) error {
for ; positional < len(values); positional++ {
arg := values[positional]
// TODO(aat): Fix hardcoding of these env checks all over the place :\
if arg.Tag.Env != "" {
_, ok := os.LookupEnv(arg.Tag.Env)
if ok {
if len(arg.Tag.Envs) != 0 {
if atLeastOneEnvSet(arg.Tag.Envs) {
continue
}
}
Expand Down Expand Up @@ -997,3 +996,12 @@ func isValidatable(v reflect.Value) validatable {
}
return nil
}

func atLeastOneEnvSet(envs []string) bool {
for _, env := range envs {
if _, ok := os.LookupEnv(env); ok {
return true
}
}
return false
}
13 changes: 11 additions & 2 deletions help.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ type HelpValueFormatter func(value *Value) string

// DefaultHelpValueFormatter is the default HelpValueFormatter.
func DefaultHelpValueFormatter(value *Value) string {
if value.Tag.Env == "" || HasInterpolatedVar(value.OrigHelp, "env") {
if len(value.Tag.Envs) == 0 || HasInterpolatedVar(value.OrigHelp, "env") {
return value.Help
}
suffix := "($" + value.Tag.Env + ")"
suffix := "(" + formatEnvs(value.Tag.Envs) + ")"
switch {
case strings.HasSuffix(value.Help, "."):
return value.Help[:len(value.Help)-1] + " " + suffix + "."
Expand Down Expand Up @@ -567,3 +567,12 @@ func TreeIndenter(prefix string) string {
}
return "|" + strings.Repeat(" ", defaultIndent) + prefix
}

func formatEnvs(envs []string) string {
formatted := make([]string, len(envs))
for i := range envs {
formatted[i] = "$" + envs[i]
}

return strings.Join(formatted, ", ")
}
48 changes: 48 additions & 0 deletions help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,18 @@ func TestEnvarAutoHelp(t *testing.T) {
assert.Contains(t, w.String(), "A flag ($FLAG).")
}

func TestMultipleEnvarAutoHelp(t *testing.T) {
var cli struct {
Flag string `env:"FLAG1,FLAG2" help:"A flag."`
}
w := &strings.Builder{}
p := mustNew(t, &cli, kong.Writers(w, w), kong.Exit(func(int) {}))
_, err := p.Parse([]string{"--help"})
assert.NoError(t, err)
assert.Contains(t, w.String(), "A flag ($FLAG1, $FLAG2).")
}

//nolint:dupl // false positive
func TestEnvarAutoHelpWithEnvPrefix(t *testing.T) {
type Anonymous struct {
Flag string `env:"FLAG" help:"A flag."`
Expand All @@ -488,6 +500,24 @@ func TestEnvarAutoHelpWithEnvPrefix(t *testing.T) {
assert.Contains(t, w.String(), "A different flag.")
}

//nolint:dupl // false positive
func TestMultipleEnvarAutoHelpWithEnvPrefix(t *testing.T) {
type Anonymous struct {
Flag string `env:"FLAG1,FLAG2" help:"A flag."`
Other string `help:"A different flag."`
}
var cli struct {
Anonymous `envprefix:"ANON_"`
}
w := &strings.Builder{}
p := mustNew(t, &cli, kong.Writers(w, w), kong.Exit(func(int) {}))
_, err := p.Parse([]string{"--help"})
assert.NoError(t, err)
assert.Contains(t, w.String(), "A flag ($ANON_FLAG1, $ANON_FLAG2).")
assert.Contains(t, w.String(), "A different flag.")
}

//nolint:dupl // false positive
func TestCustomValueFormatter(t *testing.T) {
var cli struct {
Flag string `env:"FLAG" help:"A flag."`
Expand All @@ -505,6 +535,24 @@ func TestCustomValueFormatter(t *testing.T) {
assert.Contains(t, w.String(), "A flag.")
}

//nolint:dupl // false positive
func TestMultipleCustomValueFormatter(t *testing.T) {
var cli struct {
Flag string `env:"FLAG1,FLAG2" help:"A flag."`
}
w := &strings.Builder{}
p := mustNew(t, &cli,
kong.Writers(w, w),
kong.Exit(func(int) {}),
kong.ValueFormatter(func(value *kong.Value) string {
return value.Help
}),
)
_, err := p.Parse([]string{"--help"})
assert.NoError(t, err)
assert.Contains(t, w.String(), "A flag.")
}

func TestAutoGroup(t *testing.T) {
var cli struct {
GroupedAString string `help:"A string flag grouped in A."`
Expand Down
13 changes: 9 additions & 4 deletions kong.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,16 @@ func (k *Kong) interpolateValue(value *Value, vars Vars) (err error) {
return fmt.Errorf("enum value for %s: %s", value.Summary(), err)
}
if value.Flag != nil {
if value.Flag.Env, err = interpolate(value.Flag.Env, vars, nil); err != nil {
return fmt.Errorf("env value for %s: %s", value.Summary(), err)
for i, env := range value.Flag.Envs {
if value.Flag.Envs[i], err = interpolate(env, vars, nil); err != nil {
return fmt.Errorf("env value for %s: %s", value.Summary(), err)
}
}
value.Tag.Envs = value.Flag.Envs
updatedVars["env"] = ""
if len(value.Flag.Envs) != 0 {
updatedVars["env"] = value.Flag.Envs[0]
}
value.Tag.Env = value.Flag.Env
updatedVars["env"] = value.Flag.Env
}
value.Help, err = interpolate(value.Help, vars, updatedVars)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion kong_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ func TestInterpolationIntoModel(t *testing.T) {
assert.Equal(t, map[string]bool{"a": true, "b": true, "c": true, "d": true}, flag.EnumMap())
assert.Equal(t, []string{"a", "b", "c", "d"}, flag.EnumSlice())
assert.Equal(t, "One of a,b", flag2.Help)
assert.Equal(t, "SAVE_THE_QUEEN", flag3.Env)
assert.Equal(t, []string{"SAVE_THE_QUEEN"}, flag3.Envs)
assert.Equal(t, "God SAVE_THE_QUEEN", flag3.Help)
}

Expand Down
19 changes: 11 additions & 8 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,14 +366,17 @@ func (v *Value) ApplyDefault() error {
// Does not include resolvers.
func (v *Value) Reset() error {
v.Target.Set(reflect.Zero(v.Target.Type()))
if v.Tag.Env != "" {
envar := os.Getenv(v.Tag.Env)
if envar != "" {
err := v.Parse(ScanFromTokens(Token{Type: FlagValueToken, Value: envar}), v.Target)
if err != nil {
return fmt.Errorf("%s (from envar %s=%q)", err, v.Tag.Env, envar)
if len(v.Tag.Envs) != 0 {
for _, env := range v.Tag.Envs {
envar := os.Getenv(env)
// Parse the first non-empty ENV in the list
if envar != "" {
err := v.Parse(ScanFromTokens(Token{Type: FlagValueToken, Value: envar}), v.Target)
if err != nil {
return fmt.Errorf("%s (from envar %s=%q)", err, env, envar)
}
return nil
}
return nil
}
}
if v.HasDefault {
Expand All @@ -393,7 +396,7 @@ type Flag struct {
Group *Group // Logical grouping when displaying. May also be used by configuration loaders to group options logically.
Xor []string
PlaceHolder string
Env string
Envs []string
Short rune
Hidden bool
Negated bool
Expand Down
12 changes: 6 additions & 6 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,21 +451,21 @@ func siftStrings(ss []string, filter func(s string) bool) []string {
// --some.value -> PREFIX_SOME_VALUE
func DefaultEnvars(prefix string) Option {
processFlag := func(flag *Flag) {
switch env := flag.Env; {
switch env := flag.Envs; {
case flag.Name == "help":
return
case env == "-":
flag.Env = ""
case len(env) == 1 && env[0] == "-":
flag.Envs = nil
return
case env != "":
case len(env) > 0:
return
}
replacer := strings.NewReplacer("-", "_", ".", "_")
names := append([]string{prefix}, camelCase(replacer.Replace(flag.Name))...)
names = siftStrings(names, func(s string) bool { return !(s == "_" || strings.TrimSpace(s) == "") })
name := strings.ToUpper(strings.Join(names, "_"))
flag.Env = name
flag.Value.Tag.Env = name
flag.Envs = append(flag.Envs, name)
flag.Value.Tag.Envs = append(flag.Value.Tag.Envs, name)
}

var processNode func(node *Node)
Expand Down
37 changes: 37 additions & 0 deletions resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ func TestEnvarsFlagBasic(t *testing.T) {
assert.Equal(t, "foo", cli.Interp)
}

func TestEnvarsFlagMultiple(t *testing.T) {
var cli struct {
FirstENVPresent string `env:"KONG_TEST1_1,KONG_TEST1_2"`
SecondENVPresent string `env:"KONG_TEST2_1,KONG_TEST2_2"`
}
parser, unsetEnvs := newEnvParser(t, &cli,
envMap{
"KONG_TEST1_1": "value1.1",
"KONG_TEST1_2": "value1.2",
"KONG_TEST2_2": "value2.2",
},
)
defer unsetEnvs()

_, err := parser.Parse([]string{})
assert.NoError(t, err)
assert.Equal(t, "value1.1", cli.FirstENVPresent)
assert.Equal(t, "value2.2", cli.SecondENVPresent)
}

func TestEnvarsFlagOverride(t *testing.T) {
var cli struct {
Flag string `env:"KONG_FLAG"`
Expand Down Expand Up @@ -97,6 +117,23 @@ func TestEnvarsEnvPrefix(t *testing.T) {
assert.Equal(t, []int{1, 2, 3}, cli.Slice)
}

func TestEnvarsEnvPrefixMultiple(t *testing.T) {
type Anonymous struct {
Slice1 []int `env:"NUMBERS1_1,NUMBERS1_2"`
Slice2 []int `env:"NUMBERS2_1,NUMBERS2_2"`
}
var cli struct {
Anonymous `envprefix:"KONG_"`
}
parser, restoreEnv := newEnvParser(t, &cli, envMap{"KONG_NUMBERS1_1": "1,2,3", "KONG_NUMBERS2_2": "5,6,7"})
defer restoreEnv()

_, err := parser.Parse([]string{})
assert.NoError(t, err)
assert.Equal(t, []int{1, 2, 3}, cli.Slice1)
assert.Equal(t, []int{5, 6, 7}, cli.Slice2)
}

func TestEnvarsNestedEnvPrefix(t *testing.T) {
type NestedAnonymous struct {
String string `env:"STRING"`
Expand Down
6 changes: 4 additions & 2 deletions tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type Tag struct {
Default string
Format string
PlaceHolder string
Env string
Envs []string
Short rune
Hidden bool
Sep rune
Expand Down Expand Up @@ -234,7 +234,9 @@ func hydrateTag(t *Tag, typ reflect.Type) error { // nolint: gocyclo
t.Help = t.Get("help")
t.Type = t.Get("type")
t.TypeName = typeName
t.Env = t.Get("env")
for _, env := range t.GetAll("env") {
t.Envs = append(t.Envs, strings.FieldsFunc(env, tagSplitFn)...)
}
t.Short, err = t.GetRune("short")
if err != nil && t.Get("short") != "" {
return fmt.Errorf("invalid short flag name %q: %s", t.Get("short"), err)
Expand Down

0 comments on commit 9610ed6

Please sign in to comment.