Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add StringMap flag #1590

Merged
merged 2 commits into from
Dec 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,34 @@ func ExampleApp_Run_sliceValues() {
// error: <nil>
}

func ExampleApp_Run_mapValues() {
// set args for examples sake
os.Args = []string{
"multi_values",
"--stringMap", "parsed1=parsed two", "--stringMap", "parsed3=",
}
app := NewApp()
app.Name = "multi_values"
app.Flags = []Flag{
&StringMapFlag{Name: "stringMap"},
}
app.Action = func(ctx *Context) error {
for i, v := range ctx.FlagNames() {
fmt.Printf("%d-%s %#v\n", i, v, ctx.StringMap(v))
}
fmt.Printf("notfound %#v\n", ctx.StringMap("notfound"))
err := ctx.Err()
fmt.Println("error:", err)
return err
}

_ = app.Run(os.Args)
// Output:
// 0-stringMap map[string]string{"parsed1":"parsed two", "parsed3":""}
// notfound map[string]string(nil)
// error: <nil>
}

func TestApp_Run(t *testing.T) {
s := ""

Expand Down Expand Up @@ -2874,6 +2902,16 @@ func TestFlagAction(t *testing.T) {
return nil
},
},
&StringMapFlag{
Name: "f_string_map",
Action: func(c *Context, v map[string]string) error {
if _, ok := v["err"]; ok {
return fmt.Errorf("error string map")
}
c.App.Writer.Write([]byte(fmt.Sprintf("%v", v)))
return nil
},
},
},
Action: func(ctx *Context) error { return nil },
}
Expand Down Expand Up @@ -3034,6 +3072,16 @@ func TestFlagAction(t *testing.T) {
args: []string{"app", "--f_string=app", "--f_uint=1", "--f_int_slice=1,2,3", "--f_duration=1h30m20s", "c1", "--f_string=c1", "sub1", "--f_string=sub1"},
exp: "app 1h30m20s [1 2 3] 1 c1 sub1 ",
},
{
name: "flag_string_map",
args: []string{"app", "--f_string_map=s1=s2,s3="},
exp: "map[s1:s2 s3:]",
},
{
name: "flag_string_map_error",
args: []string{"app", "--f_string_map=err="},
err: fmt.Errorf("error string map"),
},
}

for _, test := range tests {
Expand Down
5 changes: 3 additions & 2 deletions flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import (
const defaultPlaceholder = "value"

var (
defaultSliceFlagSeparator = ","
disableSliceFlagSeparator = false
defaultSliceFlagSeparator = ","
defaultMapFlagKeyValueSeparator = "="
disableSliceFlagSeparator = false
)

var (
Expand Down
4 changes: 2 additions & 2 deletions flag_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ func (f *FlagBase[T, C, V]) RunAction(ctx *Context) error {
// values from cmd line. This is true for slice and map type flags
func (f *FlagBase[T, C, VC]) IsMultiValueFlag() bool {
// TBD how to specify
return reflect.TypeOf(f.Value).Kind() == reflect.Slice ||
reflect.TypeOf(f.Value).Kind() == reflect.Map
kind := reflect.TypeOf(f.Value).Kind()
return kind == reflect.Slice || kind == reflect.Map
}

// IsPersistent returns true if flag needs to be persistent across subcommands
Expand Down
116 changes: 116 additions & 0 deletions flag_map_impl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package cli

import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
)

// MapBase wraps map[string]T to satisfy flag.Value
type MapBase[T any, C any, VC ValueCreator[T, C]] struct {
dict *map[string]T
hasBeenSet bool
value Value
}

func (i MapBase[T, C, VC]) Create(val map[string]T, p *map[string]T, c C) Value {
*p = map[string]T{}
for k, v := range val {
(*p)[k] = v
}
var t T
np := new(T)
var vc VC
return &MapBase[T, C, VC]{
dict: p,
value: vc.Create(t, np, c),
}
}

// NewMapBase makes a *MapBase with default values
func NewMapBase[T any, C any, VC ValueCreator[T, C]](defaults map[string]T) *MapBase[T, C, VC] {
return &MapBase[T, C, VC]{
dict: &defaults,
}
}

// Set parses the value and appends it to the list of values
func (i *MapBase[T, C, VC]) Set(value string) error {
if !i.hasBeenSet {
*i.dict = map[string]T{}
i.hasBeenSet = true
}

if strings.HasPrefix(value, slPfx) {
// Deserializing assumes overwrite
_ = json.Unmarshal([]byte(strings.Replace(value, slPfx, "", 1)), &i.dict)
i.hasBeenSet = true
return nil
}

for _, item := range flagSplitMultiValues(value) {
key, value, ok := strings.Cut(item, defaultMapFlagKeyValueSeparator)
if !ok {
return fmt.Errorf("item %q is missing separator %q", item, defaultMapFlagKeyValueSeparator)
}
if err := i.value.Set(value); err != nil {
return err
}
tmp, ok := i.value.Get().(T)
if !ok {
return fmt.Errorf("unable to cast %v", i.value)
}
(*i.dict)[key] = tmp
}

return nil
}

// String returns a readable representation of this value (for usage defaults)
func (i *MapBase[T, C, VC]) String() string {
v := i.Value()
var t T
if reflect.TypeOf(t).Kind() == reflect.String {
return fmt.Sprintf("%v", v)
}
return fmt.Sprintf("%T{%s}", v, i.ToString(v))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a simple test case to test map[string]int to enable codecov of this line ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about this, but shouldn't that be added together with IntMap or similar?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you could do that.

}

// Serialize allows MapBase to fulfill Serializer
func (i *MapBase[T, C, VC]) Serialize() string {
jsonBytes, _ := json.Marshal(i.dict)
return fmt.Sprintf("%s%s", slPfx, string(jsonBytes))
}

// Value returns the mapping of values set by this flag
func (i *MapBase[T, C, VC]) Value() map[string]T {
if i.dict == nil {
return map[string]T{}
}
return *i.dict
}

// Get returns the mapping of values set by this flag
func (i *MapBase[T, C, VC]) Get() interface{} {
return *i.dict
}

func (i MapBase[T, C, VC]) ToString(t map[string]T) string {
var defaultVals []string
var vc VC
for _, k := range sortedKeys(t) {
defaultVals = append(defaultVals, k+defaultMapFlagKeyValueSeparator+vc.ToString(t[k]))
}
return strings.Join(defaultVals, ", ")
}

func sortedKeys[T any](dict map[string]T) []string {
keys := make([]string, 0, len(dict))
for k := range dict {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
35 changes: 28 additions & 7 deletions flag_string.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,31 @@ package cli

import (
"fmt"
"strings"
)

type StringFlag = FlagBase[string, NoConfig, stringValue]
type StringFlag = FlagBase[string, StringConfig, stringValue]

// StringConfig defines the configuration for string flags
type StringConfig struct {
// Whether to trim whitespace of parsed value
TrimSpace bool
}

// -- string Value
type stringValue string
type stringValue struct {
destination *string
trimSpace bool
}

// Below functions are to satisfy the ValueCreator interface

func (i stringValue) Create(val string, p *string, c NoConfig) Value {
func (i stringValue) Create(val string, p *string, c StringConfig) Value {
*p = val
return (*stringValue)(p)
return &stringValue{
destination: p,
trimSpace: c.TrimSpace,
}
}

func (i stringValue) ToString(b string) string {
Expand All @@ -26,13 +39,21 @@ func (i stringValue) ToString(b string) string {
// Below functions are to satisfy the flag.Value interface

func (s *stringValue) Set(val string) error {
*s = stringValue(val)
if s.trimSpace {
val = strings.TrimSpace(val)
}
*s.destination = val
return nil
}

func (s *stringValue) Get() any { return string(*s) }
func (s *stringValue) Get() any { return *s.destination }

func (s *stringValue) String() string { return string(*s) }
func (s *stringValue) String() string {
if s.destination != nil {
return *s.destination
}
return ""
}

func (cCtx *Context) String(name string) string {
if v, ok := cCtx.Value(name).(string); ok {
Expand Down
27 changes: 27 additions & 0 deletions flag_string_map.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cli

import "flag"

type StringMap = MapBase[string, StringConfig, stringValue]
type StringMapFlag = FlagBase[map[string]string, StringConfig, StringMap]

var NewStringMap = NewMapBase[string, StringConfig, stringValue]

// StringMap looks up the value of a local StringMapFlag, returns
// nil if not found
func (cCtx *Context) StringMap(name string) map[string]string {
if fs := cCtx.lookupFlagSet(name); fs != nil {
return lookupStringMap(name, fs)
}
return nil
}

func lookupStringMap(name string, set *flag.FlagSet) map[string]string {
f := set.Lookup(name)
if f != nil {
if mapping, ok := f.Value.(*StringMap); ok {
return mapping.Value()
}
}
return nil
}
6 changes: 3 additions & 3 deletions flag_string_slice.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import (
"flag"
)

type StringSlice = SliceBase[string, NoConfig, stringValue]
type StringSliceFlag = FlagBase[[]string, NoConfig, StringSlice]
type StringSlice = SliceBase[string, StringConfig, stringValue]
type StringSliceFlag = FlagBase[[]string, StringConfig, StringSlice]

var NewStringSlice = NewSliceBase[string, NoConfig, stringValue]
var NewStringSlice = NewSliceBase[string, StringConfig, stringValue]

// StringSlice looks up the value of a local StringSliceFlag, returns
// nil if not found
Expand Down
Loading