Browse Source

Add config.WithValuesFromMap

master
Tyler Sommer 4 months ago
parent
commit
8c027049cf
Signed by: tyler-sommer GPG Key ID: C09C010500DBD008
6 changed files with 346 additions and 173 deletions
  1. +1
    -119
      config/option.go
  2. +60
    -0
      config/option_flags_test.go
  3. +1
    -53
      config/option_inherit_test.go
  4. +58
    -0
      config/option_map_test.go
  5. +200
    -0
      config/option_post.go
  6. +26
    -1
      config/setup.go

+ 1
- 119
config/option.go View File

@ -1,12 +1,7 @@
package config
import (
"flag"
"strings"
"github.com/BurntSushi/toml"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// A Section describes the pre-configured state of a nested configuration
@ -145,120 +140,7 @@ func WithInheritedOption(name string) SetupOption {
}
// WithInheritedSection will inherit a section from the parent Config.
// Alias for WithInheritedOption.
func WithInheritedSection(name string) SetupOption {
return WithInheritedOption(name)
}
// WithValuesFromTOMLFile will populate the Config with values parsed from a
// TOML file.
func WithValuesFromTOMLFile(filename string) SetupOption {
return func(s *Setup) error {
if s.raw == nil {
s.raw = make(map[string]interface{})
}
if _, err := toml.DecodeFile(filename, &s.raw); err != nil {
return err
}
return nil
}
}
type flagMapper struct {
s *Setup
}
func newFlagMapper(s *Setup) *flagMapper {
return &flagMapper{s}
}
func (fm *flagMapper) normalize(name string) string {
return strings.ToLower(
strings.ReplaceAll(name, "-", "_"))
}
// Map converts a flag name into a path based on sections and options.
// If a Config has sections "A" and "B" has an option "c", then the flag
// named "-a-b-c" would be converted into the path ["A","B","c"].
func (fm *flagMapper) Map(flagName string) (path []string) {
normal := fm.normalize(flagName)
s := fm.s
loop:
for s != nil {
// a valueInspector here handles struct tag aliases.
is, err := inspect(s.initial)
if err != nil {
logrus.Debugf("config: unable to create valueInspector for %s: %s", s.name, err)
} else {
if _, err := is.Get(normal); err == nil {
path = append(path, normal)
s = nil
goto loop
} else {
logrus.Debugf("config: valueInspector returned error for %s in %s: %s", normal, s.name, err)
}
}
// check for a match in options next
for k := range s.options {
kn := fm.normalize(k)
if kn == normal {
// found it
path = append(path, k)
s = nil
goto loop
}
}
// check for a matching section, using the name as a prefix
for k, ks := range s.sections {
kn := fm.normalize(k) + "_"
if strings.HasPrefix(normal, kn) {
// found the next step in the path
normal = strings.Replace(normal, kn, "", 1)
path = append(path, k)
s = ks
goto loop
}
}
return nil
}
return path
}
// WithValuesFromFlagSet populates the Config using command-line flags.
func WithValuesFromFlagSet(fs *flag.FlagSet) SetupOption {
return func(s *Setup) error {
if !fs.Parsed() {
return errors.Errorf("given FlagSet must be parsed")
}
if s.raw == nil {
s.raw = make(map[string]interface{})
}
m := newFlagMapper(s)
fs.Visit(func(f *flag.Flag) {
path := m.Map(f.Name)
if len(path) == 0 {
logrus.Debugf("config: did not match anything for flag '%s' for section %s", f.Name, s.name)
return
}
var val interface{} = f.Value.String()
if fg, ok := f.Value.(flag.Getter); ok {
val = fg.Get()
}
v := s.raw
i := 0
for i = 0; i < len(path)-1; i++ {
if vs, ok := v[path[i]].(map[string]interface{}); ok {
v = vs
} else {
if vr, ok := v[path[i]]; ok {
logrus.Debugf("config: overriding existing value in raw config for %s -- was type %T", f.Name, vr)
}
nv := make(map[string]interface{})
v[path[i]] = nv
v = nv
}
}
v[path[i]] = val
})
return nil
}
}

+ 60
- 0
config/option_flags_test.go View File

@ -0,0 +1,60 @@
package config_test
import (
"flag"
"fmt"
"testing"
"code.dopame.me/veonik/squircy3/config"
)
func TestWithValuesFromFlagSet(t *testing.T) {
type Config struct {
Name string
Bio *struct {
Age int
}
}
co := &Config{"veonik", &struct{ Age int }{30}}
fs := flag.NewFlagSet("", flag.ExitOnError)
fs.String("name", "", "your name")
fs.Int("bio-age", 0, "you age")
if err := fs.Parse([]string{"-name", "tyler", "-bio-age", "31"}); err != nil {
t.Errorf("unexpected error parsing flagset: %s", err)
return
}
c, err := config.Wrap(co,
config.WithRequiredOption("Name"),
config.WithGenericSection("Bio", config.WithRequiredOption("Age")),
config.WithValuesFromFlagSet(fs))
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
fmt.Printf("Hi, %s!\n", co.Name)
n, ok := c.String("Name")
if !ok {
t.Errorf("expected name to be a string")
return
}
b, err := c.Section("Bio")
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
a, ok := b.Int("Age")
if !ok {
t.Errorf("expected age to be an int")
return
}
if co.Name != n {
t.Errorf("expected Name option (%s) to match Name field on Config struct (%s)", n, co.Name)
}
if co.Bio.Age != a {
t.Errorf("expected Bio.Age option (%d) to match Age field on Bio struct (%d)", a, co.Bio.Age)
}
fmt.Printf("%s is %d.\n", n, a)
// Outputs:
// Hi, tyler!
// tyler is 31.
}

config/option_test.go → config/option_inherit_test.go View File

@ -1,64 +1,12 @@
package config_test
import (
"flag"
"fmt"
"testing"
"code.dopame.me/veonik/squircy3/config"
)
func TestWithValuesFromFlagSet(t *testing.T) {
type Config struct {
Name string
Bio *struct {
Age int
}
}
co := &Config{"veonik", &struct{ Age int }{30}}
fs := flag.NewFlagSet("", flag.ExitOnError)
fs.String("name", "", "your name")
fs.Int("bio-age", 0, "you age")
if err := fs.Parse([]string{"-name", "tyler", "-bio-age", "31"}); err != nil {
t.Errorf("unexpected error parsing flagset: %s", err)
return
}
c, err := config.Wrap(co,
config.WithRequiredOption("Name"),
config.WithGenericSection("Bio", config.WithRequiredOption("Age")),
config.WithValuesFromFlagSet(fs))
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
fmt.Printf("Hi, %s!\n", co.Name)
n, ok := c.String("Name")
if !ok {
t.Errorf("expected name to be a string")
return
}
b, err := c.Section("Bio")
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
a, ok := b.Int("Age")
if !ok {
t.Errorf("expected age to be an int")
return
}
if co.Name != n {
t.Errorf("expected Name option (%s) to match Name field on Config struct (%s)", n, co.Name)
}
if co.Bio.Age != a {
t.Errorf("expected Bio.Age option (%d) to match Age field on Bio struct (%d)", a, co.Bio.Age)
}
fmt.Printf("%s is %d.\n", n, a)
// Outputs:
// Hi, tyler!
// tyler is 31.
}
func TestWithInheritedOption(t *testing.T) {
type Config struct {
BasePath string
@ -120,7 +68,7 @@ func TestWithInheritedSection(t *testing.T) {
config.WithInitValue(map[string]interface{}{"Var": "test"})),
config.WithGenericSection(
"Sub",
config.WithInheritedOption("Test")))
config.WithInheritedSection("Test")))
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return

+ 58
- 0
config/option_map_test.go View File

@ -0,0 +1,58 @@
package config_test
import (
"fmt"
"testing"
"code.dopame.me/veonik/squircy3/config"
)
func TestWithValuesFromMap(t *testing.T) {
type Config struct {
Name string
Bio *struct {
Age int
}
}
co := &Config{"veonik", &struct{ Age int }{30}}
opts := map[string]interface{}{
"Name": "tyler",
"Bio": map[string]interface{}{
"Age": 31,
},
}
c, err := config.Wrap(co,
config.WithRequiredOption("Name"),
config.WithGenericSection("Bio", config.WithRequiredOption("Age")),
config.WithValuesFromMap(opts))
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
fmt.Printf("Hi, %s!\n", co.Name)
n, ok := c.String("Name")
if !ok {
t.Errorf("expected name to be a string")
return
}
b, err := c.Section("Bio")
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
a, ok := b.Int("Age")
if !ok {
t.Errorf("expected age to be an int")
return
}
if co.Name != n {
t.Errorf("expected Name option (%s) to match Name field on Config struct (%s)", n, co.Name)
}
if co.Bio.Age != a {
t.Errorf("expected Bio.Age option (%d) to match Age field on Bio struct (%d)", a, co.Bio.Age)
}
fmt.Printf("%s is %d.\n", n, a)
// Outputs:
// Hi, tyler!
// tyler is 31.
}

+ 200
- 0
config/option_post.go View File

@ -0,0 +1,200 @@
package config
import (
"flag"
"regexp"
"strings"
"github.com/BurntSushi/toml"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// populateValuesFromTOMLFile runs after other SetupOptions and does the actual
// filling of values in the Config from the TOML file.
func populateValuesFromTOMLFile(filename string) postSetupOption {
return func(s *Setup) error {
if s.raw == nil {
s.raw = make(map[string]interface{})
}
if _, err := toml.DecodeFile(filename, &s.raw); err != nil {
return err
}
return nil
}
}
// WithValuesFromTOMLFile will populate the Config with values parsed from a
// TOML file.
func WithValuesFromTOMLFile(filename string) SetupOption {
return func(s *Setup) error {
return s.addPostSetup(populateValuesFromTOMLFile(filename))
}
}
var camelCaseMatcher = regexp.MustCompile("(^[^A-Z]*|[A-Z]*)([A-Z][^A-Z]+|$)")
var dashAndSpaceMatcher = regexp.MustCompile("([-\\s])")
// a nameFieldMapper converts a flag name into path parts that correspond to
// sections and options defined in the Setup.
type nameFieldMapper struct {
s *Setup
}
func newNameFieldMapper(s *Setup) *nameFieldMapper {
return &nameFieldMapper{s}
}
// normalize converts the given name into a normal, underscorized name.
// Dashes are converted to underscores, camel case is separated by underscores
// and converts everything to lower-case.
func (fm *nameFieldMapper) normalize(name string) string {
name = dashAndSpaceMatcher.ReplaceAllString(name, "_")
var a []string
for _, sub := range camelCaseMatcher.FindAllStringSubmatch(name, -1) {
if sub[1] != "" {
a = append(a, sub[1])
}
if sub[2] != "" {
a = append(a, sub[2])
}
}
return strings.ToLower(strings.Join(a, "_"))
}
// Map converts a flag name into a path based on sections and options.
// If a Config has a section "Templating" which contains the section "Twig"
// which has an option "views_path", then:
// -templating-twig-views-path
// is converted into the path:
// ["Templating","Twig","views_path"]
func (fm *nameFieldMapper) Map(flagName string) (path []string) {
normal := fm.normalize(flagName)
s := fm.s
loop:
for s != nil {
// a valueInspector here handles struct tag aliases.
is, err := inspect(s.initial)
if err != nil {
logrus.Debugf("config: unable to create valueInspector for section '%s': %s", s.name, err)
} else {
if _, err := is.Get(normal); err == nil {
path = append(path, normal)
logrus.Debugf("config: valueInspector found match for field '%s' in section '%s'", normal, s.name)
s = nil
goto loop
} else {
logrus.Debugf("config: valueInspector returned error for '%s' in section '%s': %s", normal, s.name, err)
}
}
// check for a match in options next
for k := range s.options {
kn := fm.normalize(k)
if kn == normal {
// found it
path = append(path, k)
logrus.Debugf("config: found option with name '%s' in section '%s'", normal, s.name)
s = nil
goto loop
}
}
// check for a matching section, using the name as a prefix
for k, ks := range s.sections {
kn := fm.normalize(k) + "_"
if strings.HasPrefix(normal, kn) {
// found the next step in the path
normal = strings.Replace(normal, kn, "", 1)
path = append(path, k)
logrus.Debugf("config: descending into section %s (from %s) to find match for option '%s'", ks.name, s.name, normal)
s = ks
goto loop
}
}
return nil
}
return path
}
func visitNamedOption(s *Setup, f string, fv interface{}, m *nameFieldMapper) {
path := m.Map(f)
if len(path) == 0 {
logrus.Debugf("config: did not match anything for named option '%s' for section %s", f, s.name)
return
}
logrus.Debugf("config: named option '%s' mapped to path: %v", f, path)
logrus.Debugf("config: named option '%s' setting to: %T(%v)", f, fv, fv)
val := fv
v := s.raw
i := 0
// iterate over all but the last part of the path, descending into a
// new section with each iteration.
for i = 0; i < len(path)-1; i++ {
if vs, ok := v[path[i]].(map[string]interface{}); ok {
v = vs
} else {
if vr, ok := v[path[i]]; ok {
// there is no path[0-1] so figure out the name accordingly
secn := s.name
if i > 0 {
secn = path[i-1]
}
logrus.Debugf("config: overriding existing value in section %s for option '%s' -- was type %T", secn, path[i], vr)
}
nv := make(map[string]interface{})
v[path[i]] = nv
v = nv
}
}
// use the last element in the path to set the right option.
v[path[i]] = val
}
func populateValuesFromFlagSet(fs *flag.FlagSet) postSetupOption {
return func(s *Setup) error {
if !fs.Parsed() {
return errors.Errorf("given FlagSet must be parsed")
}
if s.raw == nil {
s.raw = make(map[string]interface{})
}
m := newNameFieldMapper(s)
fs.Visit(func(f *flag.Flag) {
var v interface{} = f.Value.String()
if fv, ok := f.Value.(flag.Getter); ok {
v = fv.Get()
}
visitNamedOption(s, f.Name, v, m)
})
return nil
}
}
// WithValuesFromFlagSet populates the Config using command-line flags.
func WithValuesFromFlagSet(fs *flag.FlagSet) SetupOption {
return func(s *Setup) error {
if !fs.Parsed() {
return errors.Errorf("given FlagSet must be parsed")
}
return s.addPostSetup(populateValuesFromFlagSet(fs))
}
}
func populateValuesFromMap(vs map[string]interface{}) postSetupOption {
return func(s *Setup) error {
if s.raw == nil {
s.raw = make(map[string]interface{})
}
m := newNameFieldMapper(s)
for f, fv := range vs {
visitNamedOption(s, f, fv, m)
}
return nil
}
}
// WithValuesFromMap populates the Config using the given map.
func WithValuesFromMap(vs map[string]interface{}) SetupOption {
return func(s *Setup) error {
return s.addPostSetup(populateValuesFromMap(vs))
}
}

+ 26
- 1
config/setup.go View File

@ -9,6 +9,14 @@ import (
// A SetupOption is a function that modifies the given Setup in some way.
type SetupOption func(c *Setup) error
// A postSetupOption is a SetupOption that runs after all other SetupOptions.
// SetupOptions that populate the config from a data source (ie. options with
// method name like "WithValuesFrom*") are examples of postSetupOptions. This
// allows postSetupOptions to consume the metadata stored within the Setup
// while populating values from the data source.
type postSetupOption func(c *Setup) error
// A protoFunc is a function that returns the initial value for a config.
type protoFunc func() Value
// Setup is a container struct with information on how to setup a given Config.
@ -26,6 +34,8 @@ type Setup struct {
sections map[string]*Setup
options map[string]bool
inherits map[string]struct{}
post []postSetupOption
}
func newSetup(name string, parent *Setup) *Setup {
@ -43,17 +53,32 @@ func newSetup(name string, parent *Setup) *Setup {
}
}
// addPostSetup adds one or more postSetupOptions.
func (s *Setup) addPostSetup(options ...postSetupOption) error {
s.post = append(s.post, options...)
return nil
}
// apply calls each SetupOption, halting on the first error encountered.
func (s *Setup) apply(options ...SetupOption) error {
// clear post options, they will be re-added by the regular options.
s.post = []postSetupOption{}
// apply regular options.
for _, o := range options {
if err := o(s); err != nil {
return err
}
}
// apply post-setup options.
for _, o := range s.post {
if err := o(s); err != nil {
return err
}
}
return nil
}
// validate checks that all required options are set, recursively.
// validate checks that all options and sections are valid, recursively.
func (s *Setup) validate() error {
if s.config == nil {
return errors.New(`expected config to be populated, found nil`)

Loading…
Cancel
Save