From c037c0b7874ef5ef26662b641376a6788bca0a4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tanguy=20=E2=A7=93=20Herrmann?= Date: Mon, 15 Jun 2020 02:17:14 +0200 Subject: [PATCH] add .env file support --- ffenv/env.go | 57 +++++++ ffenv/env_test.go | 296 +++++++++++++++++++++++++++++++++++ ffenv/testdata/1.env | 4 + ffenv/testdata/2.env | 4 + ffenv/testdata/3.env | 4 + ffenv/testdata/4.env | 4 + ffenv/testdata/5.env | 5 + ffenv/testdata/equals.env | 1 + ffenv/testdata/prefix.env | 5 + ffenv/testdata/solo_bool.env | 2 + ffenv/testdata/undefined.env | 2 + 11 files changed, 384 insertions(+) create mode 100644 ffenv/env.go create mode 100644 ffenv/env_test.go create mode 100644 ffenv/testdata/1.env create mode 100644 ffenv/testdata/2.env create mode 100644 ffenv/testdata/3.env create mode 100644 ffenv/testdata/4.env create mode 100644 ffenv/testdata/5.env create mode 100644 ffenv/testdata/equals.env create mode 100644 ffenv/testdata/prefix.env create mode 100644 ffenv/testdata/solo_bool.env create mode 100644 ffenv/testdata/undefined.env diff --git a/ffenv/env.go b/ffenv/env.go new file mode 100644 index 0000000..c9c8329 --- /dev/null +++ b/ffenv/env.go @@ -0,0 +1,57 @@ +package ffenv + +import ( + "bufio" + "io" + "strings" +) + +// Parser is a parser for .env file format: flag=value. Each +// line is tokenized as a single key/value pair. +func Parser(r io.Reader, set func(name, value string) error) error { + return parse("", r, set) +} + +func parse(prefix string, r io.Reader, set func(name, value string) error) error { + s := bufio.NewScanner(r) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" { + continue // skip empties + } + + if line[0] == '#' { + continue // skip comments + } + + line = strings.TrimPrefix(line, prefix+"_") + var ( + name string + value string + index = strings.IndexRune(line, '=') + ) + if index < 0 { + name, value = line, "true" // boolean option + } else { + name, value = strings.ToLower(line[:index]), line[index+1:] + name = strings.ReplaceAll(name, "_", "-") + } + + if i := strings.Index(value, " #"); i >= 0 { + value = strings.TrimSpace(value[:i]) + } + + if err := set(name, value); err != nil { + return err + } + } + return nil +} + +// ParserWithPrefix removes any prefix_ on keys in a .env file. +// MY_APP_PREFIX_KEY=value will get evaluated as key=value. +func ParserWithPrefix(prefix string) func(io.Reader, func(string, string) error) error { + return func(r io.Reader, set func(name, value string) error) error { + return parse(prefix, r, set) + } +} diff --git a/ffenv/env_test.go b/ffenv/env_test.go new file mode 100644 index 0000000..e971627 --- /dev/null +++ b/ffenv/env_test.go @@ -0,0 +1,296 @@ +package ffenv_test + +import ( + "os" + "testing" + "time" + + "github.com/peterbourgon/ff/v3" + "github.com/peterbourgon/ff/v3/ffenv" + "github.com/peterbourgon/ff/v3/fftest" +) + +func TestParseBasics(t *testing.T) { + t.Parallel() + + for _, testcase := range []struct { + name string + env map[string]string + file string + args []string + opts []ff.Option + want fftest.Vars + }{ + { + name: "empty", + args: []string{}, + want: fftest.Vars{}, + }, + { + name: "args only", + args: []string{"-s", "foo", "-i", "123", "-b", "-d", "24m"}, + want: fftest.Vars{S: "foo", I: 123, B: true, D: 24 * time.Minute}, + }, + { + name: "file only", + file: "testdata/1.env", + want: fftest.Vars{S: "bar", I: 99, B: true, D: time.Hour}, + }, + { + name: "env only", + env: map[string]string{"TEST_PARSE_S": "baz", "TEST_PARSE_F": "0.99", "TEST_PARSE_D": "100s"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: fftest.Vars{S: "baz", F: 0.99, D: 100 * time.Second}, + }, + { + name: "file args", + file: "testdata/2.env", + args: []string{"-s", "foo", "-i", "1234"}, + want: fftest.Vars{S: "foo", I: 1234, D: 3 * time.Second}, + }, + { + name: "env args", + env: map[string]string{"TEST_PARSE_S": "should be overridden", "TEST_PARSE_B": "true"}, + args: []string{"-s", "explicit wins", "-i", "7"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: fftest.Vars{S: "explicit wins", I: 7, B: true}, + }, + { + name: "file env", + env: map[string]string{"TEST_PARSE_S": "env takes priority", "TEST_PARSE_B": "true"}, + file: "testdata/3.env", + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: fftest.Vars{S: "env takes priority", I: 99, B: true, D: 34 * time.Second}, + }, + { + name: "file env args", + file: "testdata/4.env", + env: map[string]string{"TEST_PARSE_S": "from env", "TEST_PARSE_I": "300", "TEST_PARSE_F": "0.15", "TEST_PARSE_B": "true"}, + args: []string{"-s", "from arg", "-i", "100"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: fftest.Vars{S: "from arg", I: 100, F: 0.15, B: true, D: time.Minute}, + }, + { + name: "repeated args", + args: []string{"-s", "foo", "-s", "bar", "-d", "1m", "-d", "1h", "-x", "1", "-x", "2", "-x", "3"}, + want: fftest.Vars{S: "bar", D: time.Hour, X: []string{"1", "2", "3"}}, + }, + { + name: "priority repeats", + env: map[string]string{"TEST_PARSE_S": "s.env", "TEST_PARSE_X": "x.env.1"}, + file: "testdata/5.env", + args: []string{"-s", "s.arg.1", "-s", "s.arg.2", "-x", "x.arg.1", "-x", "x.arg.2"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: fftest.Vars{S: "s.arg.2", X: []string{"x.arg.1", "x.arg.2"}}, // highest prio wins and no others are called + }, + { + name: "PlainParser solo bool", + file: "testdata/solo_bool.env", + want: fftest.Vars{S: "x", B: true}, + }, + { + name: "PlainParser string with spaces", + file: "testdata/equals.env", + want: fftest.Vars{S: "i=am=the=very=model=of=a=modern=major=general"}, + }, + { + name: "default comma behavior", + env: map[string]string{"TEST_PARSE_S": "one,two,three", "TEST_PARSE_X": "one,two,three"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE")}, + want: fftest.Vars{S: "one,two,three", X: []string{"one,two,three"}}, + }, + { + name: "WithEnvVarSplit", + env: map[string]string{"TEST_PARSE_S": "one,two,three", "TEST_PARSE_X": "one,two,three"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithEnvVarSplit(",")}, + want: fftest.Vars{S: "three", X: []string{"one", "two", "three"}}, + }, + { + name: "WithEnvVarNoPrefix", + env: map[string]string{"TEST_PARSE_S": "foo", "S": "bar"}, + opts: []ff.Option{ff.WithEnvVarNoPrefix()}, + want: fftest.Vars{S: "bar"}, + }, + { + name: "WithIgnoreUndefined env", + env: map[string]string{"TEST_PARSE_UNDEFINED": "one", "TEST_PARSE_S": "one"}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithIgnoreUndefined(true)}, + want: fftest.Vars{S: "one"}, + }, + { + name: "WithIgnoreUndefined file true", + file: "testdata/undefined.env", + opts: []ff.Option{ff.WithIgnoreUndefined(true)}, + want: fftest.Vars{S: "one"}, + }, + { + name: "WithIgnoreUndefined file false", + file: "testdata/undefined.env", + opts: []ff.Option{ff.WithIgnoreUndefined(false)}, + want: fftest.Vars{WantParseErrorString: "config file flag"}, + }, + { + name: "env var split comma whitespace", + env: map[string]string{"TEST_PARSE_S": "one, two, three ", "TEST_PARSE_X": "one, two, three "}, + opts: []ff.Option{ff.WithEnvVarPrefix("TEST_PARSE"), ff.WithEnvVarSplit(",")}, + want: fftest.Vars{S: " three ", X: []string{"one", " two", " three "}}, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + if testcase.file != "" { + testcase.opts = append(testcase.opts, ff.WithConfigFile(testcase.file), ff.WithConfigFileParser(ffenv.Parser)) + } + + if len(testcase.env) > 0 { + for k, v := range testcase.env { + defer os.Setenv(k, os.Getenv(k)) + os.Setenv(k, v) + } + } + + fs, vars := fftest.Pair() + vars.ParseError = ff.Parse(fs, testcase.args, testcase.opts...) + if err := fftest.Compare(&testcase.want, vars); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestParseIssue16(t *testing.T) { + t.Parallel() + + for _, testcase := range []struct { + name string + data string + want string + }{ + { + name: "hash in value", + data: "s=bar#baz", + want: "bar#baz", + }, + { + name: "EOL comment with space", + data: "s=bar # baz", + want: "bar", + }, + { + name: "EOL comment no space", + data: "s=bar #baz", + want: "bar", + }, + { + name: "only comment with space", + data: "# foo=bar\n", + want: "", + }, + { + name: "only comment no space", + data: "#foo=bar\n", + want: "", + }, + } { + t.Run(testcase.name, func(t *testing.T) { + filename, cleanup := fftest.TempFile(t, testcase.data) + defer cleanup() + + fs, vars := fftest.Pair() + vars.ParseError = ff.Parse(fs, []string{}, + ff.WithConfigFile(filename), + ff.WithConfigFileParser(ffenv.Parser), + ) + + want := fftest.Vars{S: testcase.want} + if err := fftest.Compare(&want, vars); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestParseConfigFile(t *testing.T) { + t.Parallel() + + for _, testcase := range []struct { + name string + missing bool + allowMissing bool + parseError error + }{ + { + name: "has config file", + }, + { + name: "config file missing", + missing: true, + parseError: os.ErrNotExist, + }, + { + name: "config file missing + allow missing", + missing: true, + allowMissing: true, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + filename := "dummy" + if !testcase.missing { + var cleanup func() + filename, cleanup = fftest.TempFile(t, "") + defer cleanup() + } + + options := []ff.Option{ff.WithConfigFile(filename), ff.WithConfigFileParser(ffenv.Parser)} + if testcase.allowMissing { + options = append(options, ff.WithAllowMissingConfigFile(true)) + } + + fs, vars := fftest.Pair() + vars.ParseError = ff.Parse(fs, []string{}, options...) + + want := fftest.Vars{WantParseErrorIs: testcase.parseError} + if err := fftest.Compare(&want, vars); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestParseClassicEnvFileWithPrefixUpperCase(t *testing.T) { + t.Parallel() + + for _, testcase := range []struct { + name string + env map[string]string + file string + args []string + opts []ff.Option + want fftest.Vars + }{ + { + name: "Parser with prefix", + file: "testdata/prefix.env", + want: fftest.Vars{S: "this", I: 1, F: 1.3, B: true, D: 10 * time.Second}, + }, + } { + t.Run(testcase.name, func(t *testing.T) { + if testcase.file != "" { + testcase.opts = append(testcase.opts, ff.WithConfigFile(testcase.file), ff.WithEnvVarSplit(","), ff.WithConfigFileParser(ffenv.ParserWithPrefix("TEST_PARSE"))) + } + + if len(testcase.env) > 0 { + for k, v := range testcase.env { + defer os.Setenv(k, os.Getenv(k)) + os.Setenv(k, v) + } + } + + fs, vars := fftest.Pair() + vars.ParseError = ff.Parse(fs, testcase.args, testcase.opts...) + t.Log("vars:", vars) + if err := fftest.Compare(&testcase.want, vars); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/ffenv/testdata/1.env b/ffenv/testdata/1.env new file mode 100644 index 0000000..5779214 --- /dev/null +++ b/ffenv/testdata/1.env @@ -0,0 +1,4 @@ +s=bar +i=99 +b=true +d=1h diff --git a/ffenv/testdata/2.env b/ffenv/testdata/2.env new file mode 100644 index 0000000..9d07afc --- /dev/null +++ b/ffenv/testdata/2.env @@ -0,0 +1,4 @@ + +s=should be overridden + +d=3s diff --git a/ffenv/testdata/3.env b/ffenv/testdata/3.env new file mode 100644 index 0000000..1b1ecf9 --- /dev/null +++ b/ffenv/testdata/3.env @@ -0,0 +1,4 @@ +s=bar +i=99 +d=34s +# comment line diff --git a/ffenv/testdata/4.env b/ffenv/testdata/4.env new file mode 100644 index 0000000..0b2a9af --- /dev/null +++ b/ffenv/testdata/4.env @@ -0,0 +1,4 @@ +s=from file +i=200 # comment +d=1m +f=2.3 diff --git a/ffenv/testdata/5.env b/ffenv/testdata/5.env new file mode 100644 index 0000000..bda3679 --- /dev/null +++ b/ffenv/testdata/5.env @@ -0,0 +1,5 @@ +s=s.file.1 +s=s.file.2 + +x=x.file.1 +x=x.file.2 diff --git a/ffenv/testdata/equals.env b/ffenv/testdata/equals.env new file mode 100644 index 0000000..3a4539d --- /dev/null +++ b/ffenv/testdata/equals.env @@ -0,0 +1 @@ +s=i=am=the=very=model=of=a=modern=major=general diff --git a/ffenv/testdata/prefix.env b/ffenv/testdata/prefix.env new file mode 100644 index 0000000..accd25f --- /dev/null +++ b/ffenv/testdata/prefix.env @@ -0,0 +1,5 @@ +TEST_PARSE_S=this +TEST_PARSE_I=1 +TEST_PARSE_F=1.3 +TEST_PARSE_D=10s +TEST_PARSE_B=true diff --git a/ffenv/testdata/solo_bool.env b/ffenv/testdata/solo_bool.env new file mode 100644 index 0000000..8ceb028 --- /dev/null +++ b/ffenv/testdata/solo_bool.env @@ -0,0 +1,2 @@ +b +s=x diff --git a/ffenv/testdata/undefined.env b/ffenv/testdata/undefined.env new file mode 100644 index 0000000..0b768b2 --- /dev/null +++ b/ffenv/testdata/undefined.env @@ -0,0 +1,2 @@ +undef undefined variable +s=one