Repository: alecthomas/kong
Pull request #349 (2023-01-31 14:44:50)

feat: support multiple env variables
Solve #154 


Patch:
diff --git a/README.md b/README.md
index 389f006..35b07c6 100644
--- a/README.md
+++ b/README.md
@@ -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.                                                                                                                                                                                                                                                                          |
diff --git a/build.go b/build.go
index 0a23fd0..e23c115 100644
--- a/build.go
+++ b/build.go
@@ -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.
@@ -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,
diff --git a/context.go b/context.go
index de9e408..0ec10f4 100644
--- a/context.go
+++ b/context.go
@@ -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
 				}
@@ -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
 			}
 		}
@@ -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
+}
diff --git a/help.go b/help.go
index b265121..4fb7700 100644
--- a/help.go
+++ b/help.go
@@ -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 + "."
@@ -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, ", ")
+}
diff --git a/kong.go b/kong.go
index 70e8906..255633f 100644
--- a/kong.go
+++ b/kong.go
@@ -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 {
diff --git a/model.go b/model.go
index 1428965..793cf97 100644
--- a/model.go
+++ b/model.go
@@ -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 {
@@ -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
diff --git a/options.go b/options.go
index 8633f68..8d2893c 100644
--- a/options.go
+++ b/options.go
@@ -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)
diff --git a/tag.go b/tag.go
index cac4074..f99059b 100644
--- a/tag.go
+++ b/tag.go
@@ -24,7 +24,7 @@ type Tag struct {
 	Default     string
 	Format      string
 	PlaceHolder string
-	Env         string
+	Envs        []string
 	Short       rune
 	Hidden      bool
 	Sep         rune
@@ -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)


Test patch:
diff --git a/help_test.go b/help_test.go
index f93084b..2c94660 100644
--- a/help_test.go
+++ b/help_test.go
@@ -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."`
@@ -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."`
@@ -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."`
diff --git a/kong_test.go b/kong_test.go
index 2dd7cd3..72c73cf 100644
--- a/kong_test.go
+++ b/kong_test.go
@@ -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)
 }
 
diff --git a/resolver_test.go b/resolver_test.go
index 3c4ded0..c1684b6 100644
--- a/resolver_test.go
+++ b/resolver_test.go
@@ -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"`
@@ -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"`