Skip to content

Commit

Permalink
Make cli help flags work properly in combination with other sources
Browse files Browse the repository at this point in the history
  • Loading branch information
NHAS committed Nov 10, 2024
1 parent b9cf030 commit a078e1d
Show file tree
Hide file tree
Showing 6 changed files with 141 additions and 45 deletions.
29 changes: 20 additions & 9 deletions cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,19 +264,28 @@ func LoadCliWithTransform[T any](delimiter string, transform func(string) string
return
}

func (cp *ciParser[T]) apply(result *T) (err error) {
func (cp *ciParser[T]) usage(f *flag.FlagSet) func() {

return func() {
fmt.Fprintf(f.Output(), "Structure options: \n")
f.PrintDefaults()
}
}

func (cp *ciParser[T]) apply(result *T) (somethingSet bool, err error) {

if len(os.Args) == 0 {
logger.Info("no os arguments supplied, not trying to parse cli")
return nil
return false, nil
}

CommandLine := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
CommandLine.SetOutput(os.Stdout)
CommandLine.Usage = cp.usage(CommandLine)
if len(os.Args) <= 1 {
logger.Info("one os arguments supplied, not trying to parse cli")
// There were no args to parse, so the user must not be using the cli
return nil
return false, nil
}

// stop go flag from overwritting literally all configuration data on default write
Expand All @@ -290,7 +299,8 @@ func (cp *ciParser[T]) apply(result *T) (err error) {

flagAssociation := map[string]association{}

CommandLine.Bool("confy-help", true, "Print command line flags generated by confy")
const sourceHelpFlag = "struct-help"
CommandLine.Bool(sourceHelpFlag, true, "Print command line flags generated by confy")
for _, field := range getFields(true, dummyCopy) {

willAccess := field.value.CanAddr() && field.value.CanInterface()
Expand Down Expand Up @@ -389,13 +399,13 @@ func (cp *ciParser[T]) apply(result *T) (err error) {
}
err = CommandLine.Parse(os.Args[1:])
if err != nil {
return err
return false, err
}

help := false
CommandLine.Visit(func(f *flag.Flag) {
if f.Name == "confy-help" {
logger.Info("the help flag -confy-help was set")
if f.Name == sourceHelpFlag {
logger.Info("the help flag was set", "flag", sourceHelpFlag)

help = true
return
Expand All @@ -409,14 +419,15 @@ func (cp *ciParser[T]) apply(result *T) (err error) {
v, _ := getField(result, association.path)

v.Set(association.v)
somethingSet = true

logger.Info("CLI FLAG", "-"+f.Name, maskSensitive(f.Value.String(), association.tag))
})

if help {
CommandLine.PrintDefaults()
return flag.ErrHelp
return somethingSet, flag.ErrHelp
}

return nil
return somethingSet, nil
}
27 changes: 19 additions & 8 deletions config_file_parsing.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,23 @@ func (cp *configParser[T]) setArray(targetArray, values reflect.Value) reflect.V
return result
}

func (cp *configParser[T]) setField(v interface{}, fieldPath []string, value reflect.Value) {
func (cp *configParser[T]) setField(v interface{}, fieldPath []string, value reflect.Value) bool {
r := reflect.ValueOf(v).Elem()

somethingSet := false
for i, part := range fieldPath {
if i == len(fieldPath)-1 {
f := r.FieldByName(part)
if f.IsValid() && f.CanAddr() {
if f.Type().Kind() != reflect.Array && f.Type().Kind() != reflect.Slice {
somethingSet = true

f.Set(value)
} else {
// Due to the yaml parser being incredibly dumb, we have had to recursively go in to every struct
// and make sure it has a yaml tag if the type is complex
f.Set(cp.setArray(f, value))
somethingSet = true
}
} else {

Expand All @@ -110,6 +114,8 @@ func (cp *configParser[T]) setField(v interface{}, fieldPath []string, value ref
r = r.FieldByName(part)
}
}

return somethingSet
}

func (cp *configParser[T]) getAllTagNames(tag reflect.StructTag) (result []string) {
Expand Down Expand Up @@ -213,21 +219,24 @@ func newConfigLoader[T any](o *options) *configParser[T] {
}
}

func (cp *configParser[T]) apply(result *T) (err error) {
func (cp *configParser[T]) apply(result *T) (somethingSet bool, err error) {
if cp.o.config.dataMethod == nil {
panic("No data method available for getting config data, this is a mistake")
}

clone, err := cp.cloneWithNewTags(result)
if err != nil {
return err
return false, err
}

logger.Info(fmt.Sprintf("constructed value (with auto added tags): %#v", clone))

configData, configType, err := cp.o.config.dataMethod()
if err != nil {
return err
if cp.o.config.required {
return false, fmt.Errorf("%w: %s", errFatal, err)
}
return false, err
}

type configDecoder interface {
Expand All @@ -253,23 +262,25 @@ func (cp *configParser[T]) apply(result *T) (err error) {
}
decoder = tmlDec
default:
return errors.New("config type could not be determined")
return false, errors.New("config type could not be determined")
}

err = decoder.Decode(clone)
if err != nil {
return fmt.Errorf("failed to decode config: %s", err)
return false, fmt.Errorf("failed to decode config: %s", err)
}

fields := getFields(false, clone)

for _, value := range fields {
logger.Info("setting field of config file", "path", strings.Join(value.path, "."), "value", value.value.String(), "tag", value.tag)

cp.setField(result, value.path, value.value)
if cp.setField(result, value.path, value.value) {
somethingSet = true
}
}

return nil
return somethingSet, nil
}

// CloneWithNewTags creates a new struct with modified tags, leaves it blank
Expand Down
91 changes: 67 additions & 24 deletions entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type OptionFunc func(*options) error

type configDataOptions struct {
strictParsing bool
required bool

dataMethod func() (io.Reader, ConfigType, error)
}
Expand Down Expand Up @@ -122,48 +123,75 @@ func Config[T any](suppliedOptions ...OptionFunc) (result T, warnings []error, e
currentlySet: make(map[preference]bool),
}

for _, optFunc := range suppliedOptions {
err := optFunc(&o)
if err != nil {
return result, nil, err
}
orderLoadOpts := map[preference]loader[T]{
cli: newCliLoader[T](&o),
env: newEnvLoader[T](&o),
configFile: newConfigLoader[T](&o),
}

if len(o.order) == 0 {
if err := Defaults("config", "config.json")(&o); err != nil {
if errors.Is(err, flag.ErrHelp) && slices.Contains(o.order, cli) {
orderLoadOpts[cli].apply(&result)
}

return result, nil, err
}
}
} else {

orderLoadOpts := map[preference]loader[T]{
cli: newCliLoader[T](&o),
env: newEnvLoader[T](&o),
configFile: newConfigLoader[T](&o),
var errs []error
for _, optFunc := range suppliedOptions {
err := optFunc(&o)
if err != nil {
errs = append(errs, err)
}
}

cErr := errors.Join(errs...)
if cErr != nil {
// special case, if cli is enabled print out the help from that too
if errors.Is(err, flag.ErrHelp) && slices.Contains(o.order, cli) {
orderLoadOpts[cli].apply(&result)
}
return result, nil, cErr
}
}

logger.Info("Populating configuration in this order: ", slog.Any("order", o.order))

anythingWasSet := false
for _, p := range o.order {

f, ok := orderLoadOpts[p]
if !ok {
panic("unknown preference option: " + p)
}

err := f.apply(&result)
somethingWasSet, err := f.apply(&result)
if err != nil {

if errors.Is(err, errFatal) {
return result, nil, err
}

if len(o.order) > 1 && !errors.Is(err, flag.ErrHelp) {
logger.Warn("parser issued warning", "parser", p, "err", err.Error())

warnings = append(warnings, err)
} else if errors.Is(err, flag.ErrHelp) && slices.Contains(o.order, cli) && p != cli {
err = orderLoadOpts[cli].apply(&result)
return result, nil, err
} else {
logger.Error("parser issued error", "parser", p, "err", err.Error())
return result, nil, err
}
}

if somethingWasSet {
anythingWasSet = true
}

}

if !anythingWasSet {
return result, warnings, fmt.Errorf("nothing was set in configuration from sources: %s", o.order)
}

return
Expand All @@ -188,21 +216,25 @@ func Defaults(cliFlag, defaultPath string) OptionFunc {
return func(c *options) error {

// Process in config file -> env -> cli order
errs := []error{}
err := FromConfigFileFlagPath(cliFlag, defaultPath, "config file path", Auto)(c)
if err != nil {
return err
errs = append(errs, err)
}

err = FromEnvs(DefaultENVDelimiter)(c)
if err != nil {
return err
errs = append(errs, err)

}
err = FromCli(DefaultCliDelimiter)(c)
if err != nil {
return err
errs = append(errs, err)

}
WithLogLevel(slog.LevelError)

return nil
return errors.Join(errs...)
}
}

Expand All @@ -214,21 +246,23 @@ func DefaultsFromPath(path string) OptionFunc {
return func(c *options) error {

// Process in config file -> env -> cli order
errs := []error{}

err := FromConfigFile(path, Auto)(c)
if err != nil {
return err
errs = append(errs, err)
}
err = FromEnvs(DefaultENVDelimiter)(c)
if err != nil {
return err
errs = append(errs, err)
}
err = FromCli(DefaultCliDelimiter)(c)
if err != nil {
return err
errs = append(errs, err)
}
WithLogLevel(slog.LevelError)

return nil
return errors.Join(errs...)
}
}

Expand Down Expand Up @@ -394,8 +428,8 @@ func FromConfigFileFlagPath(cliFlagName, defaultPath, description string, config

configPath := commandLine.String(cliFlagName, defaultPath, description)
if err := commandLine.Parse(os.Args[1:]); err != nil {
if err == flag.ErrHelp {
commandLine.PrintDefaults()
if errors.Is(err, flag.ErrHelp) {

return flag.ErrHelp
}

Expand All @@ -415,6 +449,15 @@ func WithStrictParsing() OptionFunc {
}
}

// WithConfigRequired causes failure to load the configuration from file/bytes/url to become fatal rather than just warning
func WithConfigRequired() OptionFunc {
return func(c *options) error {
c.config.required = true
return nil
}

}

// FromEnvs sets confy to automatically populate the configuration structure from environment variables
// delimiter: string when looking for environment variables this string should be used for denoting nested structures
// e.g
Expand Down
25 changes: 25 additions & 0 deletions entry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,28 @@ func TestConfigBasic(t *testing.T) {
t.Fatal(err)
}
}

func TestConfigurationNothingSet(t *testing.T) {
os.Args = []string{
"dummy",
}

_, _, err := Config[testStruct](Defaults("config", "config.yaml"))
if err == nil {
t.Fatal("should return that help was asked for")
}

}

func TestConfigurationHelp(t *testing.T) {
os.Args = []string{
"dummy", "-h",
}

_, _, err := Config[testStruct](Defaults("config", "config.yaml"))
if err == nil {
t.Fatal("should return that help was asked for")
}

t.Fail()
}
Loading

0 comments on commit a078e1d

Please sign in to comment.