Skip to content

Commit

Permalink
add .env file support
Browse files Browse the repository at this point in the history
  • Loading branch information
dolanor committed Jul 21, 2020
1 parent a2a0e27 commit c037c0b
Show file tree
Hide file tree
Showing 11 changed files with 384 additions and 0 deletions.
57 changes: 57 additions & 0 deletions ffenv/env.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
296 changes: 296 additions & 0 deletions ffenv/env_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
4 changes: 4 additions & 0 deletions ffenv/testdata/1.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
s=bar
i=99
b=true
d=1h
4 changes: 4 additions & 0 deletions ffenv/testdata/2.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

s=should be overridden

d=3s
4 changes: 4 additions & 0 deletions ffenv/testdata/3.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
s=bar
i=99
d=34s
# comment line
4 changes: 4 additions & 0 deletions ffenv/testdata/4.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
s=from file
i=200 # comment
d=1m
f=2.3
5 changes: 5 additions & 0 deletions ffenv/testdata/5.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
s=s.file.1
s=s.file.2

x=x.file.1
x=x.file.2
1 change: 1 addition & 0 deletions ffenv/testdata/equals.env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
s=i=am=the=very=model=of=a=modern=major=general
5 changes: 5 additions & 0 deletions ffenv/testdata/prefix.env
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions ffenv/testdata/solo_bool.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
b
s=x
2 changes: 2 additions & 0 deletions ffenv/testdata/undefined.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
undef undefined variable
s=one

0 comments on commit c037c0b

Please sign in to comment.