From f02da4cd10e92a63bb78dc6671a3405afb70dc4a Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Sun, 8 Oct 2023 20:39:23 -0400 Subject: [PATCH 01/27] bump go versions used in CI --- .github/workflows/go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index dcb52cf..847d8c9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - go: ['1.17', '1.18', '1.19'] + go: ['1.19', '1.20', '1.21'] steps: - id: go From 582e6d537a34c8d16bbb401b70f590d5502bbd73 Mon Sep 17 00:00:00 2001 From: guoguangwu Date: Wed, 15 Nov 2023 17:58:55 +0800 Subject: [PATCH 02/27] fix: typo Signed-off-by: guoguangwu --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f105b17..7b3d148 100644 --- a/README.md +++ b/README.md @@ -591,7 +591,7 @@ https://godoc.org/github.com/alexflint/go-arg There are many command line argument parsing libraries for Go, including one in the standard library, so why build another? -The `flag` library that ships in the standard library seems awkward to me. Positional arguments must preceed options, so `./prog x --foo=1` does what you expect but `./prog --foo=1 x` does not. It also does not allow arguments to have both long (`--foo`) and short (`-f`) forms. +The `flag` library that ships in the standard library seems awkward to me. Positional arguments must precede options, so `./prog x --foo=1` does what you expect but `./prog --foo=1 x` does not. It also does not allow arguments to have both long (`--foo`) and short (`-f`) forms. Many third-party argument parsing libraries are great for writing sophisticated command line interfaces, but feel to me like overkill for a simple script with a few flags. From 84ddf1d244f4bbe299f082e1c880c34831f49c57 Mon Sep 17 00:00:00 2001 From: James Shubin Date: Wed, 28 Feb 2024 22:29:16 -0500 Subject: [PATCH 03/27] add an example for environment vars with arg names If you want to specify both of these, and if they should have different names, then this shows you how it can be done. --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index f105b17..3ea28cc 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,23 @@ $ WORKERS='1,99' ./example Workers: [1 99] ``` +You can also have an environment variable that doesn't match the arg name: + +```go +var args struct { + Workers int `arg:"--count,env:NUM_WORKERS"` +} +arg.MustParse(&args) +fmt.Println("Workers:", args.Workers) +``` + +``` +$ NUM_WORKERS=6 ./example +Workers: 6 +$ NUM_WORKERS=6 ./example --count 4 +Workers: 4 +``` + ### Usage strings ```go var args struct { From 8e35a4f0d4616f11c322e63dc3ad373fe3d25e0d Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Sun, 31 Mar 2024 10:30:12 -0400 Subject: [PATCH 04/27] handle explicit empty placeholders --- parse.go | 8 ++++---- usage.go | 5 ++++- usage_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/parse.go b/parse.go index 0bdddc7..9f3e687 100644 --- a/parse.go +++ b/parse.go @@ -56,7 +56,7 @@ type spec struct { env string // the name of the environment variable for this option, or empty for none defaultValue reflect.Value // default value for this option defaultString string // default value for this option, in string form to be displayed in help text - placeholder string // name of the data in help + placeholder string // placeholder string in help } // command represents a named subcommand, or the top-level command @@ -341,9 +341,8 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) { spec.help = help } - // Look at the tag - var isSubcommand bool // tracks whether this field is a subcommand - + // process each comma-separated part of the tag + var isSubcommand bool for _, key := range strings.Split(tag, ",") { if key == "" { continue @@ -407,6 +406,7 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) { } } + // placeholder is the string used in the help text like this: "--somearg PLACEHOLDER" placeholder, hasPlaceholder := field.Tag.Lookup("placeholder") if hasPlaceholder { spec.placeholder = placeholder diff --git a/usage.go b/usage.go index a9f9844..43af31e 100644 --- a/usage.go +++ b/usage.go @@ -356,7 +356,10 @@ func (p *Parser) lookupCommand(path ...string) (*command, error) { } func synopsis(spec *spec, form string) string { - if spec.cardinality == zero { + // if the user omits the placeholder tag then we pick one automatically, + // but if the user explicitly specifies an empty placeholder then we + // leave out the placeholder in the help message + if spec.cardinality == zero || spec.placeholder == "" { return form } return form + " " + spec.placeholder diff --git a/usage_test.go b/usage_test.go index 1a64ad4..1e2aca4 100644 --- a/usage_test.go +++ b/usage_test.go @@ -522,6 +522,35 @@ Options: assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) } +func TestUsageWithEmptyPlaceholder(t *testing.T) { + expectedUsage := "Usage: example [-a] [--b] [--c]" + + expectedHelp := ` +Usage: example [-a] [--b] [--c] + +Options: + -a some help for a + --b some help for b + --c, -c some help for c + --help, -h display this help and exit +` + var args struct { + ShortOnly string `arg:"-a,--" placeholder:"" help:"some help for a"` + LongOnly string `arg:"--b" placeholder:"" help:"some help for b"` + Both string `arg:"-c,--c" placeholder:"" help:"some help for c"` + } + p, err := NewParser(Config{Program: "example"}, &args) + require.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + var usage bytes.Buffer + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) +} + func TestUsageWithShortFirst(t *testing.T) { expectedUsage := "Usage: example [-c CAT] [--dog DOG]" From be792f1f8b2f3971c54e00240ea35a53512ec7bf Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Sun, 31 Mar 2024 11:52:16 -0400 Subject: [PATCH 05/27] ping From 68948b2ac14c5cda057c88f1d7cba992ca053a2c Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Sun, 31 Mar 2024 12:05:26 -0400 Subject: [PATCH 06/27] restore 100% code coverage --- parse.go | 7 ------- parse_test.go | 8 ++++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/parse.go b/parse.go index 0bdddc7..c88faae 100644 --- a/parse.go +++ b/parse.go @@ -85,13 +85,6 @@ func MustParse(dest ...interface{}) *Parser { // mustParse is a helper that facilitates testing func mustParse(config Config, dest ...interface{}) *Parser { - if config.Exit == nil { - config.Exit = os.Exit - } - if config.Out == nil { - config.Out = os.Stdout - } - p, err := NewParser(config, dest...) if err != nil { fmt.Fprintln(config.Out, err) diff --git a/parse_test.go b/parse_test.go index 06e7a76..1512800 100644 --- a/parse_test.go +++ b/parse_test.go @@ -1735,3 +1735,11 @@ func TestSubcommandGlobalFlag_InCommand_Strict_Inner(t *testing.T) { assert.False(t, args.Global) assert.True(t, args.Sub.Guard) } + +func TestExitFunctionAndOutStreamGetFilledIn(t *testing.T) { + var args struct{} + p, err := NewParser(Config{}, &args) + require.NoError(t, err) + assert.NotNil(t, p.config.Exit) // go prohibits function pointer comparison + assert.Equal(t, p.config.Out, os.Stdout) +} From 3ddfffdcd33a4419f68581e515113dbfbcfaaa7d Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Tue, 2 Apr 2024 12:05:00 -0400 Subject: [PATCH 07/27] add test for help and usage when a --version flag is present --- usage_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/usage_test.go b/usage_test.go index 1a64ad4..b6ce8e5 100644 --- a/usage_test.go +++ b/usage_test.go @@ -260,6 +260,39 @@ Options: assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) } +type userDefinedVersionFlag struct { + ShowVersion bool `arg:"--version" help:"this is a user-defined version flag"` +} + +// Version returns the version for this program +func (userDefinedVersionFlag) Version() string { + return "example 3.2.1" +} + +func TestUsageWithUserDefinedVersionFlag(t *testing.T) { + expectedUsage := "example 3.2.1\nUsage: example [--version]" + + expectedHelp := ` +example 3.2.1 +Usage: example [--version] + +Options: + --version this is a user-defined version flag + --help, -h display this help and exit +` + os.Args[0] = "example" + p, err := NewParser(Config{}, &userDefinedVersionFlag{}) + require.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + var usage bytes.Buffer + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) +} + type described struct{} // Described returns the description for this program From 8a917260c38a68937883b35da0851d3da179d9a2 Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Tue, 2 Apr 2024 12:10:52 -0400 Subject: [PATCH 08/27] add a test case with single-level subcommands --- usage_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/usage_test.go b/usage_test.go index b6ce8e5..f613471 100644 --- a/usage_test.go +++ b/usage_test.go @@ -448,6 +448,50 @@ Options: assert.Equal(t, expectedUsage, usage.String()) } +func TestUsageWithSubcommands(t *testing.T) { + expectedUsage := "Usage: example child [--values VALUES]" + + expectedHelp := ` +Usage: example child [--values VALUES] + +Options: + --values VALUES Values + +Global options: + --verbose, -v verbosity level + --help, -h display this help and exit +` + + var args struct { + Verbose bool `arg:"-v" help:"verbosity level"` + Child *struct { + Values []float64 `help:"Values"` + } `arg:"subcommand:child"` + } + + os.Args[0] = "example" + p, err := NewParser(Config{}, &args) + require.NoError(t, err) + + _ = p.Parse([]string{"child"}) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + var help2 bytes.Buffer + p.WriteHelpForSubcommand(&help2, "child") + assert.Equal(t, expectedHelp[1:], help2.String()) + + var usage bytes.Buffer + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) + + var usage2 bytes.Buffer + p.WriteUsageForSubcommand(&usage2, "child") + assert.Equal(t, expectedUsage, strings.TrimSpace(usage2.String())) +} + func TestUsageWithNestedSubcommands(t *testing.T) { expectedUsage := "Usage: example child nested [--enable] OUTPUT" From aa844c7de9f0314b1fe66b9bdcc12090c7d0905e Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Thu, 27 Jun 2024 00:02:41 +0100 Subject: [PATCH 09/27] Fix crash on errors in package-level `MustParse` --- example_test.go | 7 +++++++ parse.go | 5 +++-- parse_test.go | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/example_test.go b/example_test.go index 4bd7632..d3622bf 100644 --- a/example_test.go +++ b/example_test.go @@ -163,6 +163,7 @@ func Example_helpText() { // This is only necessary when running inside golang's runnable example harness mustParseExit = func(int) {} + mustParseOut = os.Stdout MustParse(&args) @@ -195,6 +196,7 @@ func Example_helpPlaceholder() { // This is only necessary when running inside golang's runnable example harness mustParseExit = func(int) {} + mustParseOut = os.Stdout MustParse(&args) @@ -235,6 +237,7 @@ func Example_helpTextWithSubcommand() { // This is only necessary when running inside golang's runnable example harness mustParseExit = func(int) {} + mustParseOut = os.Stdout MustParse(&args) @@ -272,6 +275,7 @@ func Example_helpTextWhenUsingSubcommand() { // This is only necessary when running inside golang's runnable example harness mustParseExit = func(int) {} + mustParseOut = os.Stdout MustParse(&args) @@ -392,6 +396,7 @@ func Example_errorText() { // This is only necessary when running inside golang's runnable example harness mustParseExit = func(int) {} + mustParseOut = os.Stdout MustParse(&args) @@ -415,6 +420,7 @@ func Example_errorTextForSubcommand() { // This is only necessary when running inside golang's runnable example harness mustParseExit = func(int) {} + mustParseOut = os.Stdout MustParse(&args) @@ -450,6 +456,7 @@ func Example_subcommand() { // This is only necessary when running inside golang's runnable example harness mustParseExit = func(int) {} + mustParseOut = os.Stdout MustParse(&args) diff --git a/parse.go b/parse.go index 251b005..98c21cd 100644 --- a/parse.go +++ b/parse.go @@ -76,12 +76,13 @@ var ErrHelp = errors.New("help requested by user") // ErrVersion indicates that the builtin --version was provided var ErrVersion = errors.New("version requested by user") -// for monkey patching in example code +// for monkey patching in example and test code var mustParseExit = os.Exit +var mustParseOut io.Writer = os.Stdout // MustParse processes command line arguments and exits upon failure func MustParse(dest ...interface{}) *Parser { - return mustParse(Config{Exit: mustParseExit}, dest...) + return mustParse(Config{Exit: mustParseExit, Out: mustParseOut}, dest...) } // mustParse is a helper that facilitates testing diff --git a/parse_test.go b/parse_test.go index fe055fe..07af7ed 100644 --- a/parse_test.go +++ b/parse_test.go @@ -692,6 +692,21 @@ func TestMustParse(t *testing.T) { assert.NotNil(t, parser) } +func TestMustParseError(t *testing.T) { + var args struct { + Foo []string `default:""` + } + var exitCode int + var stdout bytes.Buffer + mustParseExit = func(code int) { exitCode = code } + mustParseOut = &stdout + os.Args = []string{"example"} + parser := MustParse(&args) + assert.Nil(t, parser) + assert.Equal(t, -1, exitCode) + assert.Contains(t, stdout.String(), "default values are not supported for slice or map fields") +} + func TestEnvironmentVariable(t *testing.T) { var args struct { Foo string `arg:"env"` From a7c40c36a3a425dd1d28cbc97a3340aafb494d19 Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Sat, 29 Jun 2024 15:42:05 +0100 Subject: [PATCH 10/27] Use standard exit status code for usage errors * The stdlib `flags` package and most command line utilities use status code `2`. --- parse.go | 2 +- parse_test.go | 6 +++--- usage.go | 8 ++++---- usage_test.go | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/parse.go b/parse.go index 98c21cd..2bed8bf 100644 --- a/parse.go +++ b/parse.go @@ -90,7 +90,7 @@ func mustParse(config Config, dest ...interface{}) *Parser { p, err := NewParser(config, dest...) if err != nil { fmt.Fprintln(config.Out, err) - config.Exit(-1) + config.Exit(2) return nil } diff --git a/parse_test.go b/parse_test.go index 07af7ed..5bc781c 100644 --- a/parse_test.go +++ b/parse_test.go @@ -703,7 +703,7 @@ func TestMustParseError(t *testing.T) { os.Args = []string{"example"} parser := MustParse(&args) assert.Nil(t, parser) - assert.Equal(t, -1, exitCode) + assert.Equal(t, 2, exitCode) assert.Contains(t, stdout.String(), "default values are not supported for slice or map fields") } @@ -921,7 +921,7 @@ func TestParserMustParse(t *testing.T) { }{ {name: "help", args: struct{}{}, cmdLine: []string{"--help"}, code: 0, output: "display this help and exit"}, {name: "version", args: versioned{}, cmdLine: []string{"--version"}, code: 0, output: "example 3.2.1"}, - {name: "invalid", args: struct{}{}, cmdLine: []string{"invalid"}, code: -1, output: ""}, + {name: "invalid", args: struct{}{}, cmdLine: []string{"invalid"}, code: 2, output: ""}, } for _, tt := range tests { @@ -1571,7 +1571,7 @@ func TestMustParseInvalidParser(t *testing.T) { } parser := mustParse(Config{Out: &stdout, Exit: exit}, &args) assert.Nil(t, parser) - assert.Equal(t, -1, exitCode) + assert.Equal(t, 2, exitCode) } func TestMustParsePrintsHelp(t *testing.T) { diff --git a/usage.go b/usage.go index 6b578a5..f5e4b38 100644 --- a/usage.go +++ b/usage.go @@ -9,13 +9,13 @@ import ( // the width of the left column const colWidth = 25 -// Fail prints usage information to stderr and exits with non-zero status +// Fail prints usage information to p.Config.Out and exits with status code 2. func (p *Parser) Fail(msg string) { p.FailSubcommand(msg) } -// FailSubcommand prints usage information for a specified subcommand to stderr, -// then exits with non-zero status. To write usage information for a top-level +// FailSubcommand prints usage information for a specified subcommand to p.Config.Out, +// then exits with status code 2. To write usage information for a top-level // subcommand, provide just the name of that subcommand. To write usage // information for a subcommand that is nested under another subcommand, provide // a sequence of subcommand names starting with the top-level subcommand and so @@ -27,7 +27,7 @@ func (p *Parser) FailSubcommand(msg string, subcommand ...string) error { } fmt.Fprintln(p.config.Out, "error:", msg) - p.config.Exit(-1) + p.config.Exit(2) return nil } diff --git a/usage_test.go b/usage_test.go index b2bcab1..71324eb 100644 --- a/usage_test.go +++ b/usage_test.go @@ -738,7 +738,7 @@ error: something went wrong p.Fail("something went wrong") assert.Equal(t, expectedStdout[1:], stdout.String()) - assert.Equal(t, -1, exitCode) + assert.Equal(t, 2, exitCode) } func TestFailSubcommand(t *testing.T) { @@ -761,7 +761,7 @@ error: something went wrong require.NoError(t, err) assert.Equal(t, expectedStdout[1:], stdout.String()) - assert.Equal(t, -1, exitCode) + assert.Equal(t, 2, exitCode) } type lengthOf struct { From 4ed4ce751fa49ae1b04f672c1092120fd71d5b21 Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Sat, 15 Jul 2023 13:25:46 +0100 Subject: [PATCH 11/27] Better scanning of version flag in specs for help generation --- usage.go | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/usage.go b/usage.go index 6b578a5..ae98478 100644 --- a/usage.go +++ b/usage.go @@ -208,6 +208,9 @@ func (p *Parser) WriteHelpForSubcommand(w io.Writer, subcommand ...string) error positionals = append(positionals, spec) case spec.long != "": longOptions = append(longOptions, spec) + if spec.long == "version" { + hasVersionOption = true + } case spec.short != "": shortOptions = append(shortOptions, spec) case spec.short == "" && spec.long == "": @@ -215,6 +218,21 @@ func (p *Parser) WriteHelpForSubcommand(w io.Writer, subcommand ...string) error } } + // obtain a flattened list of options from all ancestors + // also determine if any ancestor has a version option spec + var globals []*spec + ancestor := cmd.parent + for ancestor != nil { + for _, spec := range ancestor.specs { + if spec.long == "version" { + hasVersionOption = true + break + } + } + globals = append(globals, ancestor.specs...) + ancestor = ancestor.parent + } + if p.description != "" { fmt.Fprintln(w, p.description) } @@ -236,28 +254,14 @@ func (p *Parser) WriteHelpForSubcommand(w io.Writer, subcommand ...string) error } for _, spec := range longOptions { p.printOption(w, spec) - if spec.long == "version" { - hasVersionOption = true - } } } - // obtain a flattened list of options from all ancestors - var globals []*spec - ancestor := cmd.parent - for ancestor != nil { - globals = append(globals, ancestor.specs...) - ancestor = ancestor.parent - } - // write the list of global options if len(globals) > 0 { fmt.Fprint(w, "\nGlobal options:\n") for _, spec := range globals { p.printOption(w, spec) - if spec.long == "version" { - hasVersionOption = true - } } } From bed89eb683e6016be7247041db3c998e57fc838c Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Thu, 18 Jan 2024 23:04:55 +0000 Subject: [PATCH 12/27] Implement scanning of version flag in specs for usage generation --- usage.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/usage.go b/usage.go index ae98478..4a056f3 100644 --- a/usage.go +++ b/usage.go @@ -48,18 +48,36 @@ func (p *Parser) WriteUsageForSubcommand(w io.Writer, subcommand ...string) erro } var positionals, longOptions, shortOptions []*spec + var hasVersionOption bool for _, spec := range cmd.specs { switch { case spec.positional: positionals = append(positionals, spec) case spec.long != "": longOptions = append(longOptions, spec) + if spec.long == "version" { + hasVersionOption = true + } case spec.short != "": shortOptions = append(shortOptions, spec) } } - if p.version != "" { + // make a list of ancestor commands so that we print with full context + // also determine if any ancestor has a version option spec + var ancestors []string + ancestor := cmd + for ancestor != nil { + for _, spec := range ancestor.specs { + if spec.long == "version" { + hasVersionOption = true + } + } + ancestors = append(ancestors, ancestor.name) + ancestor = ancestor.parent + } + + if !hasVersionOption && p.version != "" { fmt.Fprintln(w, p.version) } From c992aa8627a00b83eb94895173760b6be684d807 Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Sat, 15 Jul 2023 13:52:15 +0100 Subject: [PATCH 13/27] Add more test cases for version help/usage writing --- usage_test.go | 218 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 206 insertions(+), 12 deletions(-) diff --git a/usage_test.go b/usage_test.go index b2bcab1..b13ecbc 100644 --- a/usage_test.go +++ b/usage_test.go @@ -260,28 +260,23 @@ Options: assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) } -type userDefinedVersionFlag struct { - ShowVersion bool `arg:"--version" help:"this is a user-defined version flag"` -} - -// Version returns the version for this program -func (userDefinedVersionFlag) Version() string { - return "example 3.2.1" -} - func TestUsageWithUserDefinedVersionFlag(t *testing.T) { - expectedUsage := "example 3.2.1\nUsage: example [--version]" + expectedUsage := "Usage: example [--version]" expectedHelp := ` -example 3.2.1 Usage: example [--version] Options: --version this is a user-defined version flag --help, -h display this help and exit ` + + var args struct { + ShowVersion bool `arg:"--version" help:"this is a user-defined version flag"` + } + os.Args[0] = "example" - p, err := NewParser(Config{}, &userDefinedVersionFlag{}) + p, err := NewParser(Config{}, &args) require.NoError(t, err) var help bytes.Buffer @@ -293,6 +288,205 @@ Options: assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) } +func TestUsageWithVersionAndUserDefinedVersionFlag(t *testing.T) { + expectedUsage := "Usage: example [--version]" + + expectedHelp := ` +Usage: example [--version] + +Options: + --version this is a user-defined version flag + --help, -h display this help and exit +` + + var args struct { + versioned + ShowVersion bool `arg:"--version" help:"this is a user-defined version flag"` + } + + os.Args[0] = "example" + p, err := NewParser(Config{}, &args) + require.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + var usage bytes.Buffer + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) +} + +type subcommand struct { + Number int `arg:"-n,--number" help:"compute something on the given number"` +} + +func TestUsageWithVersionAndSubcommand(t *testing.T) { + expectedUsage := "example 3.2.1\nUsage: example []" + + expectedHelp := ` +example 3.2.1 +Usage: example [] + +Options: + --help, -h display this help and exit + --version display version and exit + +Commands: + cmd +` + + var args struct { + versioned + Cmd *subcommand `arg:"subcommand"` + } + + os.Args[0] = "example" + p, err := NewParser(Config{}, &args) + require.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + var usage bytes.Buffer + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) + + expectedUsage = "example 3.2.1\nUsage: example cmd [--number NUMBER]" + + expectedHelp = ` +example 3.2.1 +Usage: example cmd [--number NUMBER] + +Options: + --number NUMBER, -n NUMBER + compute something on the given number + --help, -h display this help and exit + --version display version and exit +` + _ = p.Parse([]string{"cmd"}) + + help = bytes.Buffer{} + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + usage = bytes.Buffer{} + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) +} + +func TestUsageWithUserDefinedVersionFlagAndSubcommand(t *testing.T) { + expectedUsage := "Usage: example [--version] []" + + expectedHelp := ` +Usage: example [--version] [] + +Options: + --version this is a user-defined version flag + --help, -h display this help and exit + +Commands: + cmd +` + + var args struct { + Cmd *subcommand `arg:"subcommand"` + ShowVersion bool `arg:"--version" help:"this is a user-defined version flag"` + } + + os.Args[0] = "example" + p, err := NewParser(Config{}, &args) + require.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + var usage bytes.Buffer + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) + + expectedUsage = "Usage: example cmd [--number NUMBER]" + + expectedHelp = ` +Usage: example cmd [--number NUMBER] + +Options: + --number NUMBER, -n NUMBER + compute something on the given number + +Global options: + --version this is a user-defined version flag + --help, -h display this help and exit +` + _ = p.Parse([]string{"cmd"}) + + help = bytes.Buffer{} + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + usage = bytes.Buffer{} + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) +} + +func TestUsageWithVersionAndUserDefinedVersionFlagAndSubcommand(t *testing.T) { + expectedUsage := "Usage: example [--version] []" + + expectedHelp := ` +Usage: example [--version] [] + +Options: + --version this is a user-defined version flag + --help, -h display this help and exit + +Commands: + cmd +` + + var args struct { + versioned + Cmd *subcommand `arg:"subcommand"` + ShowVersion bool `arg:"--version" help:"this is a user-defined version flag"` + } + + os.Args[0] = "example" + p, err := NewParser(Config{}, &args) + require.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + var usage bytes.Buffer + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) + + expectedUsage = "Usage: example cmd [--number NUMBER]" + + expectedHelp = ` +Usage: example cmd [--number NUMBER] + +Options: + --number NUMBER, -n NUMBER + compute something on the given number + +Global options: + --version this is a user-defined version flag + --help, -h display this help and exit +` + _ = p.Parse([]string{"cmd"}) + + help = bytes.Buffer{} + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) + + usage = bytes.Buffer{} + p.WriteUsage(&usage) + assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) +} + type described struct{} // Described returns the description for this program From c087d7180231ea3cfc12a79ee091786ac9954e6a Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Sat, 15 Jul 2023 15:12:58 +0100 Subject: [PATCH 14/27] Add note for version flag overriding to README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index f105b17..c644227 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,9 @@ $ ./example --version someprogram 4.3.0 ``` +> **Note** +> If a `--version` flag is defined in `args` or any subcommand, it overrides the built-in versioning. + ### Overriding option names ```go From af368523db2abaf14f899e39691b9dcd7c49fe77 Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Sun, 30 Jun 2024 10:33:03 -0400 Subject: [PATCH 15/27] Update go.yml --- .github/workflows/go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 847d8c9..d1e4e77 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - go: ['1.19', '1.20', '1.21'] + go: ['1.20', '1.21', '1.22'] steps: - id: go From 67353a8bcf2a2c30623e68eaf50075b7a6fcf1a8 Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Sun, 30 Jun 2024 10:35:08 -0400 Subject: [PATCH 16/27] Update version of github actions --- .github/workflows/go.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index d1e4e77..58e0c0e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -20,12 +20,12 @@ jobs: steps: - id: go name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go }} - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Build run: go build -v . From b8282df4c42289235a75ca9e4743ce6d70edd708 Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Sun, 30 Jun 2024 23:43:37 +0100 Subject: [PATCH 17/27] Fix testable example output comment formatting --- example_test.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/example_test.go b/example_test.go index d3622bf..9b72b90 100644 --- a/example_test.go +++ b/example_test.go @@ -201,13 +201,12 @@ func Example_helpPlaceholder() { MustParse(&args) // output: - // Usage: example [--optimize LEVEL] [--maxjobs N] SRC [DST [DST ...]] - + // // Positional arguments: // SRC // DST - + // // Options: // --optimize LEVEL, -O LEVEL // optimization level From 3673177bf97072ef223db831b659d2d66cc54a58 Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Sun, 30 Jun 2024 23:36:55 +0100 Subject: [PATCH 18/27] Move writing program version from usage to help writer * Writing the version on usage text is unexpected and confusing --- usage.go | 27 +++++---------------------- usage_test.go | 6 +++--- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/usage.go b/usage.go index 66a5be9..b84aa10 100644 --- a/usage.go +++ b/usage.go @@ -48,39 +48,17 @@ func (p *Parser) WriteUsageForSubcommand(w io.Writer, subcommand ...string) erro } var positionals, longOptions, shortOptions []*spec - var hasVersionOption bool for _, spec := range cmd.specs { switch { case spec.positional: positionals = append(positionals, spec) case spec.long != "": longOptions = append(longOptions, spec) - if spec.long == "version" { - hasVersionOption = true - } case spec.short != "": shortOptions = append(shortOptions, spec) } } - // make a list of ancestor commands so that we print with full context - // also determine if any ancestor has a version option spec - var ancestors []string - ancestor := cmd - for ancestor != nil { - for _, spec := range ancestor.specs { - if spec.long == "version" { - hasVersionOption = true - } - } - ancestors = append(ancestors, ancestor.name) - ancestor = ancestor.parent - } - - if !hasVersionOption && p.version != "" { - fmt.Fprintln(w, p.version) - } - // print the beginning of the usage string fmt.Fprintf(w, "Usage: %s", p.cmd.name) for _, s := range subcommand { @@ -254,6 +232,11 @@ func (p *Parser) WriteHelpForSubcommand(w io.Writer, subcommand ...string) error if p.description != "" { fmt.Fprintln(w, p.description) } + + if !hasVersionOption && p.version != "" { + fmt.Fprintln(w, p.version) + } + p.WriteUsageForSubcommand(w, subcommand...) // write the list of positionals diff --git a/usage_test.go b/usage_test.go index a958abb..fc8aaff 100644 --- a/usage_test.go +++ b/usage_test.go @@ -237,7 +237,7 @@ func (versioned) Version() string { } func TestUsageWithVersion(t *testing.T) { - expectedUsage := "example 3.2.1\nUsage: example" + expectedUsage := "Usage: example" expectedHelp := ` example 3.2.1 @@ -322,7 +322,7 @@ type subcommand struct { } func TestUsageWithVersionAndSubcommand(t *testing.T) { - expectedUsage := "example 3.2.1\nUsage: example []" + expectedUsage := "Usage: example []" expectedHelp := ` example 3.2.1 @@ -353,7 +353,7 @@ Commands: p.WriteUsage(&usage) assert.Equal(t, expectedUsage, strings.TrimSpace(usage.String())) - expectedUsage = "example 3.2.1\nUsage: example cmd [--number NUMBER]" + expectedUsage = "Usage: example cmd [--number NUMBER]" expectedHelp = ` example 3.2.1 From bf156d17a378826a4832340c79f07aab30578fa8 Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Mon, 22 Jul 2024 19:18:14 +0100 Subject: [PATCH 19/27] Fix help text for positional args with default and env var --- usage.go | 2 +- usage_test.go | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/usage.go b/usage.go index 66a5be9..8a7c139 100644 --- a/usage.go +++ b/usage.go @@ -260,7 +260,7 @@ func (p *Parser) WriteHelpForSubcommand(w io.Writer, subcommand ...string) error if len(positionals) > 0 { fmt.Fprint(w, "\nPositional arguments:\n") for _, spec := range positionals { - print(w, spec.placeholder, spec.help) + print(w, spec.placeholder, spec.help, withDefault(spec.defaultString), withEnv(spec.env)) } } diff --git a/usage_test.go b/usage_test.go index a958abb..fa9ace3 100644 --- a/usage_test.go +++ b/usage_test.go @@ -1015,3 +1015,72 @@ Commands: p.WriteHelp(&help) assert.Equal(t, expectedHelp[1:], help.String()) } + +func TestHelpShowsPositionalWithDefault(t *testing.T) { + expectedHelp := ` +Usage: example [FOO] + +Positional arguments: + FOO this is a positional with a default [default: bar] + +Options: + --help, -h display this help and exit +` + + var args struct { + Foo string `arg:"positional" default:"bar" help:"this is a positional with a default"` + } + + p, err := NewParser(Config{Program: "example"}, &args) + require.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) +} + +func TestHelpShowsPositionalWithEnv(t *testing.T) { + expectedHelp := ` +Usage: example [FOO] + +Positional arguments: + FOO this is a positional with an env variable [env: FOO] + +Options: + --help, -h display this help and exit +` + + var args struct { + Foo string `arg:"positional,env:FOO" help:"this is a positional with an env variable"` + } + + p, err := NewParser(Config{Program: "example"}, &args) + require.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) +} + +func TestHelpShowsPositionalWithDefaultAndEnv(t *testing.T) { + expectedHelp := ` +Usage: example [FOO] + +Positional arguments: + FOO this is a positional with a default and an env variable [default: bar, env: FOO] + +Options: + --help, -h display this help and exit +` + + var args struct { + Foo string `arg:"positional,env:FOO" default:"bar" help:"this is a positional with a default and an env variable"` + } + + p, err := NewParser(Config{Program: "example"}, &args) + require.NoError(t, err) + + var help bytes.Buffer + p.WriteHelp(&help) + assert.Equal(t, expectedHelp[1:], help.String()) +} From 7fd624cf1c8273b4891562f4903c6bbb3243e32b Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Wed, 4 Sep 2024 10:27:34 -0400 Subject: [PATCH 20/27] add info to README about programmatically reproducing behavior of MustParse --- README.md | 244 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 200 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 4d23034..9b8b071 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ $ NUM_WORKERS=4 ./example Workers: 4 ``` -You can provide multiple values using the CSV (RFC 4180) format: +You can provide multiple values in environment variables using commas: ```go var args struct { @@ -120,7 +120,7 @@ $ WORKERS='1,99' ./example Workers: [1 99] ``` -You can also have an environment variable that doesn't match the arg name: +Command line arguments take precedence over environment variables: ```go var args struct { @@ -175,20 +175,7 @@ var args struct { arg.MustParse(&args) ``` -### Default values (before v1.2) - -```go -var args struct { - Foo string - Bar bool -} -arg.Foo = "abc" -arg.MustParse(&args) -``` - -### Combining command line options, environment variables, and default values - -You can combine command line arguments, environment variables, and default values. Command line arguments take precedence over environment variables, which take precedence over default values. This means that we check whether a certain option was provided on the command line, then if not, we check for an environment variable (only if an `env` tag was provided), then if none is found, we check for a `default` tag containing a default value. +Command line arguments take precedence over environment variables, which take precedence over default values. This means that we check whether a certain option was provided on the command line, then if not, we check for an environment variable (only if an `env` tag was provided), then if none is found, we check for a `default` tag containing a default value. ```go var args struct { @@ -198,10 +185,6 @@ arg.MustParse(&args) ``` #### Ignoring environment variables and/or default values - -The values in an existing structure can be kept in-tact by ignoring environment -variables and/or default values. - ```go var args struct { Test string `arg:"-t,env:TEST" default:"something"` @@ -261,26 +244,7 @@ fmt.Println(args.UserIDs) map[john:123 mary:456] ``` -### Custom validation -```go -var args struct { - Foo string - Bar string -} -p := arg.MustParse(&args) -if args.Foo == "" && args.Bar == "" { - p.Fail("you must provide either --foo or --bar") -} -``` - -```shell -./example -Usage: samples [--foo FOO] [--bar BAR] -error: you must provide either --foo or --bar -``` - ### Version strings - ```go type args struct { ... @@ -304,6 +268,24 @@ someprogram 4.3.0 > **Note** > If a `--version` flag is defined in `args` or any subcommand, it overrides the built-in versioning. +### Custom validation +```go +var args struct { + Foo string + Bar string +} +p := arg.MustParse(&args) +if args.Foo == "" && args.Bar == "" { + p.Fail("you must provide either --foo or --bar") +} +``` + +```shell +./example +Usage: samples [--foo FOO] [--bar BAR] +error: you must provide either --foo or --bar +``` + ### Overriding option names ```go @@ -452,8 +434,6 @@ main.NameDotName{Head:"file", Tail:"txt"} ### Custom placeholders -*Introduced in version 1.3.0* - Use the `placeholder` tag to control which placeholder text is used in the usage text. ```go @@ -541,8 +521,6 @@ For more information visit github.com/alexflint/go-arg ### Subcommands -*Introduced in version 1.1.0* - Subcommands are commonly used in tools that wish to group multiple functions into a single program. An example is the `git` tool: ```shell $ git checkout [arguments specific to checking out code] @@ -603,6 +581,184 @@ if p.Subcommand() == nil { } ``` + +### Programmatic error handling + +The following reproduces the internal logic of `MustParse` for the simple case where +you are not using subcommands or --version. This allows you to respond +programatically to --help, and to any errors that come up. + +```go +var args struct { + Something string +} + +p, err := arg.NewParser(arg.Config{}, &args) +if err != nil { + log.Fatalf("there was an error in the definition of the Go struct: %v", err) +} + +err = p.Parse(os.Args[1:]) +switch { +case err == arg.ErrHelp: // indicates that user wrote "--help" on command line + p.WriteHelp(os.Stdout) + os.Exit(0) +case err != nil: + fmt.Printf("error: %v\n", err) + p.WriteUsage(os.Stdout) + os.Exit(1) +} +``` + +```shell +$ go run ./example --help +Usage: ./example --something SOMETHING + +Options: + --something SOMETHING + --help, -h display this help and exit + +$ ./example --wrong +error: unknown argument --wrong +Usage: ./example --something SOMETHING + +$ ./example +error: --something is required +Usage: ./example --something SOMETHING + +$ ./example --something abc +got "abc" +``` + +To also handle --version programatically, use the following: + +```go +type args struct { + Something string +} + +func (args) Version() string { + return "1.2.3" +} + +func main() { + var args args + p, err := arg.NewParser(arg.Config{}, &args) + if err != nil { + log.Fatalf("there was an error in the definition of the Go struct: %v", err) + } + + err = p.Parse(os.Args[1:]) + switch { + case err == arg.ErrHelp: // found "--help" on command line + p.WriteHelp(os.Stdout) + os.Exit(0) + case err == arg.ErrVersion: // found "--version" on command line + fmt.Println(args.Version()) + os.Exit(0) + case err != nil: + fmt.Printf("error: %v\n", err) + p.WriteUsage(os.Stdout) + os.Exit(1) + } + + fmt.Printf("got %q\n", args.Something) +} +``` + +```shell +$ ./example --version +1.2.3 + +$ go run ./example --help +1.2.3 +Usage: example --something SOMETHING + +Options: + --something SOMETHING + --help, -h display this help and exit + +$ ./example --wrong +1.2.3 +error: unknown argument --wrong +Usage: example --something SOMETHING + +$ ./example +error: --something is required +Usage: example --something SOMETHING + +$ ./example --something abc +got "abc" +``` + +To also handle subcommands, use this most general version (also works in absence of subcommands but +is a bit more complex): + +```go +type fetchCmd struct { + Count int +} + +type args struct { + Something string + Fetch *fetchCmd `arg:"subcommand"` +} + +func (args) Version() string { + return "1.2.3" +} + +func main() { + var args args + p, err := arg.NewParser(arg.Config{}, &args) + if err != nil { + log.Fatalf("there was an error in the definition of the Go struct: %v", err) + } + + err = p.Parse(os.Args[1:]) + switch { + case err == arg.ErrHelp: // found "--help" on command line + p.WriteHelpForSubcommand(os.Stdout, p.SubcommandNames()...) + os.Exit(0) + case err == arg.ErrVersion: // found "--version" on command line + fmt.Println(args.Version()) + os.Exit(0) + case err != nil: + fmt.Printf("error: %v\n", err) + p.WriteUsageForSubcommand(os.Stdout, p.SubcommandNames()...) + os.Exit(1) + } +}``` + +```shell +$ ./example --version +1.2.3 + +$ ./example --help +1.2.3 +Usage: example [--something SOMETHING] [] + +Options: + --something SOMETHING + --help, -h display this help and exit + --version display version and exit + +Commands: + fetch + +$ ./example fetch --help +1.2.3 +Usage: example fetch [--count COUNT] + +Options: + --count COUNT + +Global options: + --something SOMETHING + --help, -h display this help and exit + --version display version and exit +``` + ### API Documentation https://godoc.org/github.com/alexflint/go-arg @@ -619,4 +775,4 @@ The idea behind `go-arg` is that Go already has an excellent way to describe dat ### Backward compatibility notes -Earlier versions of this library required the help text to be part of the `arg` tag. This is still supported but is now deprecated. Instead, you should use a separate `help` tag, described above, which removes most of the limits on the text you can write. In particular, you will need to use the new `help` tag if your help text includes any commas. +Earlier versions of this library required the help text to be part of the `arg` tag. This is still supported but is now deprecated. Instead, you should use a separate `help` tag, described above, which makes it possible to include commas inside help text. From bdb7560b8d54a1037e3dedfa22a8cf978d289f09 Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Thu, 5 Sep 2024 17:05:51 -0400 Subject: [PATCH 21/27] Living in a monastery looking for funding The first commit to this project was on October 31, 2015, almost 9 years ago. It was [268 lines of code](https://github.com/alexflint/go-arg/commit/408290f7c2a968a0de255813e125a9ebb0a9dda6) and it worked pretty well! That was just about three and a half years after Go 1.0 was released. What fun! At that time there was no Go module system, so there was no need for versioned releases. Later, I started releasing official versions from time to time. v1.0.0 was published in December 2018. Over the years I've resisted adding a lot of features, and as a result the library is in pretty good shape. I use it in almost every Go program I write, personally, both servers/daemons and command line tools. It's nice! I live in a Buddhist monastery in Vermont now, not as a monk but as a lay practitioner. I'm working on building a form of Buddhism fit for consumption by AI systems. I love maintaining this little piece of software and I'd love some financial support to do so. I don't have a day job, and I need money to buy firewood, pay for car insurance, and travel to see my folks back home in Australia from time to time. If you use go-arg please consider sponsoring me. It would make a huge difference to me, and it will create a connection between us. I look forward to many long relationships. --- .github/FUNDING.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..215c818 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: [alexflint] +custom: ['https://alexflint.io/donate.html'] From b13a62172a12a2b2f0cfd7eeed10d846845a5f77 Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Thu, 5 Sep 2024 17:15:02 -0400 Subject: [PATCH 22/27] update api docs for Parser.Parse --- README.md | 14 ++++---------- parse.go | 9 ++++++++- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9b8b071..761af56 100644 --- a/README.md +++ b/README.md @@ -582,7 +582,7 @@ if p.Subcommand() == nil { ``` -### Programmatic error handling +### Custom handling of --help and --version The following reproduces the internal logic of `MustParse` for the simple case where you are not using subcommands or --version. This allows you to respond @@ -625,9 +625,6 @@ Usage: ./example --something SOMETHING $ ./example error: --something is required Usage: ./example --something SOMETHING - -$ ./example --something abc -got "abc" ``` To also handle --version programatically, use the following: @@ -686,13 +683,10 @@ Usage: example --something SOMETHING $ ./example error: --something is required Usage: example --something SOMETHING - -$ ./example --something abc -got "abc" ``` -To also handle subcommands, use this most general version (also works in absence of subcommands but -is a bit more complex): +To generate subcommand-specific help messages, use the following most general version +(this also works in absence of subcommands but is a bit more complex): ```go type fetchCmd struct { @@ -761,7 +755,7 @@ Global options: ### API Documentation -https://godoc.org/github.com/alexflint/go-arg +https://pkg.go.dev/github.com/alexflint/go-arg ### Rationale diff --git a/parse.go b/parse.go index 2bed8bf..8f99a21 100644 --- a/parse.go +++ b/parse.go @@ -494,7 +494,14 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) { } // Parse processes the given command line option, storing the results in the field -// of the structs from which NewParser was constructed +// of the structs from which NewParser was constructed. +// +// It returns ErrHelp if "--help" is one of the command line args and ErrVersion if +// "--version" is one of the command line args (the latter only applies if the +// destination struct passed to NewParser implements Versioned.) +// +// To respond to --help and --version in the way that MustParse does, see examples +// in the README under "Custom handling of --help and --version". func (p *Parser) Parse(args []string) error { err := p.process(args) if err != nil { From 12fffac1d812461638322f8eed15b1b740e38040 Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Thu, 5 Sep 2024 17:16:23 -0400 Subject: [PATCH 23/27] field -> fields --- parse.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parse.go b/parse.go index 8f99a21..172c7cd 100644 --- a/parse.go +++ b/parse.go @@ -493,7 +493,7 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) { return &cmd, nil } -// Parse processes the given command line option, storing the results in the field +// Parse processes the given command line option, storing the results in the fields // of the structs from which NewParser was constructed. // // It returns ErrHelp if "--help" is one of the command line args and ErrVersion if From dcb5577c2b6de26dd65a3392a6b511416366c216 Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Fri, 6 Sep 2024 09:00:13 -0400 Subject: [PATCH 24/27] Update FUNDING.yml --- .github/FUNDING.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 215c818..b0b67f3 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1 @@ -# These are supported funding model platforms - github: [alexflint] -custom: ['https://alexflint.io/donate.html'] From 9b5c76b1c4eda926cbec2442dd24021653178b6b Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Mon, 1 Jul 2024 23:07:32 +0100 Subject: [PATCH 25/27] Add support for setting a global env var prefix --- parse.go | 13 ++++++++----- parse_test.go | 52 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/parse.go b/parse.go index 172c7cd..ece7dde 100644 --- a/parse.go +++ b/parse.go @@ -131,6 +131,9 @@ type Config struct { // subcommand StrictSubcommands bool + // EnvPrefix instructs the library to use a name prefix when reading environment variables. + EnvPrefix string + // Exit is called to terminate the process with an error code (defaults to os.Exit) Exit func(int) @@ -235,7 +238,7 @@ func NewParser(config Config, dests ...interface{}) (*Parser, error) { panic(fmt.Sprintf("%s is not a pointer (did you forget an ampersand?)", t)) } - cmd, err := cmdFromStruct(name, path{root: i}, t) + cmd, err := cmdFromStruct(name, path{root: i}, t, config.EnvPrefix) if err != nil { return nil, err } @@ -285,7 +288,7 @@ func NewParser(config Config, dests ...interface{}) (*Parser, error) { return &p, nil } -func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) { +func cmdFromStruct(name string, dest path, t reflect.Type, envPrefix string) (*command, error) { // commands can only be created from pointers to structs if t.Kind() != reflect.Ptr { return nil, fmt.Errorf("subcommands must be pointers to structs but %s is a %s", @@ -372,9 +375,9 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) { case key == "env": // Use override name if provided if value != "" { - spec.env = value + spec.env = envPrefix + value } else { - spec.env = strings.ToUpper(field.Name) + spec.env = envPrefix + strings.ToUpper(field.Name) } case key == "subcommand": // decide on a name for the subcommand @@ -389,7 +392,7 @@ func cmdFromStruct(name string, dest path, t reflect.Type) (*command, error) { } // parse the subcommand recursively - subcmd, err := cmdFromStruct(cmdnames[0], subdest, field.Type) + subcmd, err := cmdFromStruct(cmdnames[0], subdest, field.Type, envPrefix) if err != nil { errs = append(errs, err.Error()) return false diff --git a/parse_test.go b/parse_test.go index 5bc781c..a932c22 100644 --- a/parse_test.go +++ b/parse_test.go @@ -28,11 +28,11 @@ func parse(cmdline string, dest interface{}) error { } func pparse(cmdline string, dest interface{}) (*Parser, error) { - return parseWithEnv(cmdline, nil, dest) + return parseWithEnv(Config{}, cmdline, nil, dest) } -func parseWithEnv(cmdline string, env []string, dest interface{}) (*Parser, error) { - p, err := NewParser(Config{}, dest) +func parseWithEnv(config Config, cmdline string, env []string, dest interface{}) (*Parser, error) { + p, err := NewParser(config, dest) if err != nil { return nil, err } @@ -231,7 +231,7 @@ func TestRequiredWithEnvOnly(t *testing.T) { var args struct { Foo string `arg:"required,--,-,env:FOO"` } - _, err := parseWithEnv("", []string{}, &args) + _, err := parseWithEnv(Config{}, "", []string{}, &args) require.Error(t, err, "environment variable FOO is required") } @@ -711,7 +711,7 @@ func TestEnvironmentVariable(t *testing.T) { var args struct { Foo string `arg:"env"` } - _, err := parseWithEnv("", []string{"FOO=bar"}, &args) + _, err := parseWithEnv(Config{}, "", []string{"FOO=bar"}, &args) require.NoError(t, err) assert.Equal(t, "bar", args.Foo) } @@ -720,7 +720,7 @@ func TestEnvironmentVariableNotPresent(t *testing.T) { var args struct { NotPresent string `arg:"env"` } - _, err := parseWithEnv("", nil, &args) + _, err := parseWithEnv(Config{}, "", nil, &args) require.NoError(t, err) assert.Equal(t, "", args.NotPresent) } @@ -729,7 +729,7 @@ func TestEnvironmentVariableOverrideName(t *testing.T) { var args struct { Foo string `arg:"env:BAZ"` } - _, err := parseWithEnv("", []string{"BAZ=bar"}, &args) + _, err := parseWithEnv(Config{}, "", []string{"BAZ=bar"}, &args) require.NoError(t, err) assert.Equal(t, "bar", args.Foo) } @@ -738,7 +738,7 @@ func TestEnvironmentVariableOverrideArgument(t *testing.T) { var args struct { Foo string `arg:"env"` } - _, err := parseWithEnv("--foo zzz", []string{"FOO=bar"}, &args) + _, err := parseWithEnv(Config{}, "--foo zzz", []string{"FOO=bar"}, &args) require.NoError(t, err) assert.Equal(t, "zzz", args.Foo) } @@ -747,7 +747,7 @@ func TestEnvironmentVariableError(t *testing.T) { var args struct { Foo int `arg:"env"` } - _, err := parseWithEnv("", []string{"FOO=bar"}, &args) + _, err := parseWithEnv(Config{}, "", []string{"FOO=bar"}, &args) assert.Error(t, err) } @@ -755,7 +755,7 @@ func TestEnvironmentVariableRequired(t *testing.T) { var args struct { Foo string `arg:"env,required"` } - _, err := parseWithEnv("", []string{"FOO=bar"}, &args) + _, err := parseWithEnv(Config{}, "", []string{"FOO=bar"}, &args) require.NoError(t, err) assert.Equal(t, "bar", args.Foo) } @@ -764,7 +764,7 @@ func TestEnvironmentVariableSliceArgumentString(t *testing.T) { var args struct { Foo []string `arg:"env"` } - _, err := parseWithEnv("", []string{`FOO=bar,"baz, qux"`}, &args) + _, err := parseWithEnv(Config{}, "", []string{`FOO=bar,"baz, qux"`}, &args) require.NoError(t, err) assert.Equal(t, []string{"bar", "baz, qux"}, args.Foo) } @@ -773,7 +773,7 @@ func TestEnvironmentVariableSliceEmpty(t *testing.T) { var args struct { Foo []string `arg:"env"` } - _, err := parseWithEnv("", []string{`FOO=`}, &args) + _, err := parseWithEnv(Config{}, "", []string{`FOO=`}, &args) require.NoError(t, err) assert.Len(t, args.Foo, 0) } @@ -782,7 +782,7 @@ func TestEnvironmentVariableSliceArgumentInteger(t *testing.T) { var args struct { Foo []int `arg:"env"` } - _, err := parseWithEnv("", []string{`FOO=1,99`}, &args) + _, err := parseWithEnv(Config{}, "", []string{`FOO=1,99`}, &args) require.NoError(t, err) assert.Equal(t, []int{1, 99}, args.Foo) } @@ -791,7 +791,7 @@ func TestEnvironmentVariableSliceArgumentFloat(t *testing.T) { var args struct { Foo []float32 `arg:"env"` } - _, err := parseWithEnv("", []string{`FOO=1.1,99.9`}, &args) + _, err := parseWithEnv(Config{}, "", []string{`FOO=1.1,99.9`}, &args) require.NoError(t, err) assert.Equal(t, []float32{1.1, 99.9}, args.Foo) } @@ -800,7 +800,7 @@ func TestEnvironmentVariableSliceArgumentBool(t *testing.T) { var args struct { Foo []bool `arg:"env"` } - _, err := parseWithEnv("", []string{`FOO=true,false,0,1`}, &args) + _, err := parseWithEnv(Config{}, "", []string{`FOO=true,false,0,1`}, &args) require.NoError(t, err) assert.Equal(t, []bool{true, false, false, true}, args.Foo) } @@ -809,7 +809,7 @@ func TestEnvironmentVariableSliceArgumentWrongCsv(t *testing.T) { var args struct { Foo []int `arg:"env"` } - _, err := parseWithEnv("", []string{`FOO=1,99\"`}, &args) + _, err := parseWithEnv(Config{}, "", []string{`FOO=1,99\"`}, &args) assert.Error(t, err) } @@ -817,7 +817,7 @@ func TestEnvironmentVariableSliceArgumentWrongType(t *testing.T) { var args struct { Foo []bool `arg:"env"` } - _, err := parseWithEnv("", []string{`FOO=one,two`}, &args) + _, err := parseWithEnv(Config{}, "", []string{`FOO=one,two`}, &args) assert.Error(t, err) } @@ -825,7 +825,7 @@ func TestEnvironmentVariableMap(t *testing.T) { var args struct { Foo map[int]string `arg:"env"` } - _, err := parseWithEnv("", []string{`FOO=1=one,99=ninetynine`}, &args) + _, err := parseWithEnv(Config{}, "", []string{`FOO=1=one,99=ninetynine`}, &args) require.NoError(t, err) assert.Len(t, args.Foo, 2) assert.Equal(t, "one", args.Foo[1]) @@ -836,11 +836,21 @@ func TestEnvironmentVariableEmptyMap(t *testing.T) { var args struct { Foo map[int]string `arg:"env"` } - _, err := parseWithEnv("", []string{`FOO=`}, &args) + _, err := parseWithEnv(Config{}, "", []string{`FOO=`}, &args) require.NoError(t, err) assert.Len(t, args.Foo, 0) } +func TestEnvironmentVariableWithPrefix(t *testing.T) { + var args struct { + Foo string `arg:"env"` + } + + _, err := parseWithEnv(Config{EnvPrefix: "MYAPP_"}, "", []string{"MYAPP_FOO=bar"}, &args) + require.NoError(t, err) + assert.Equal(t, "bar", args.Foo) +} + func TestEnvironmentVariableIgnored(t *testing.T) { var args struct { Foo string `arg:"env"` @@ -873,7 +883,7 @@ func TestRequiredEnvironmentOnlyVariableIsMissing(t *testing.T) { Foo string `arg:"required,--,env:FOO"` } - _, err := parseWithEnv("", []string{""}, &args) + _, err := parseWithEnv(Config{}, "", []string{""}, &args) assert.Error(t, err) } @@ -882,7 +892,7 @@ func TestOptionalEnvironmentOnlyVariable(t *testing.T) { Foo string `arg:"env:FOO"` } - _, err := parseWithEnv("", []string{}, &args) + _, err := parseWithEnv(Config{}, "", []string{}, &args) assert.NoError(t, err) } From cb7e5c190570d24d0768224f08a11ce8bf607b41 Mon Sep 17 00:00:00 2001 From: Hugo Hromic Date: Mon, 1 Jul 2024 23:16:51 +0100 Subject: [PATCH 26/27] Add global env prefix example to README * Also made newline separations around sections consistent * Also fixed usage of `p.Parse()` in env variable ignore example --- README.md | 50 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 761af56..e9075ba 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ fmt.Println("Input:", args.Input) fmt.Println("Output:", args.Output) ``` -``` +```shell $ ./example src.txt x.out y.out z.out Input: src.txt Output: [x.out y.out z.out] @@ -80,12 +80,12 @@ arg.MustParse(&args) fmt.Println("Workers:", args.Workers) ``` -``` +```shell $ WORKERS=4 ./example Workers: 4 ``` -``` +```shell $ WORKERS=4 ./example --workers=6 Workers: 6 ``` @@ -100,7 +100,7 @@ arg.MustParse(&args) fmt.Println("Workers:", args.Workers) ``` -``` +```shell $ NUM_WORKERS=4 ./example Workers: 4 ``` @@ -115,7 +115,7 @@ arg.MustParse(&args) fmt.Println("Workers:", args.Workers) ``` -``` +```shell $ WORKERS='1,99' ./example Workers: [1 99] ``` @@ -130,14 +130,35 @@ arg.MustParse(&args) fmt.Println("Workers:", args.Workers) ``` -``` +```shell $ NUM_WORKERS=6 ./example Workers: 6 $ NUM_WORKERS=6 ./example --count 4 Workers: 4 ``` +Configuring a global environment variable name prefix is also possible: + +```go +var args struct { + Workers int `arg:"--count,env:NUM_WORKERS"` +} + +p, err := arg.NewParser(arg.Config{ + EnvPrefix: "MYAPP_", +}, &args) + +p.MustParse(os.Args[1:]) +fmt.Println("Workers:", args.Workers) +``` + +```shell +$ MYAPP_NUM_WORKERS=6 ./example +Workers: 6 +``` + ### Usage strings + ```go var args struct { Input string `arg:"positional"` @@ -185,6 +206,7 @@ arg.MustParse(&args) ``` #### Ignoring environment variables and/or default values + ```go var args struct { Test string `arg:"-t,env:TEST" default:"something"` @@ -195,10 +217,11 @@ p, err := arg.NewParser(arg.Config{ IgnoreDefault: true, }, &args) -err = p.Parse(os.Args) +err = p.Parse(os.Args[1:]) ``` ### Arguments with multiple values + ```go var args struct { Database string @@ -214,6 +237,7 @@ Fetching the following IDs from foo: [1 2 3] ``` ### Arguments that can be specified multiple times, mixed with positionals + ```go var args struct { Commands []string `arg:"-c,separate"` @@ -231,6 +255,7 @@ Databases [db1 db2 db3] ``` ### Arguments with keys and values + ```go var args struct { UserIDs map[string]int @@ -245,6 +270,7 @@ map[john:123 mary:456] ``` ### Version strings + ```go type args struct { ... @@ -269,6 +295,7 @@ someprogram 4.3.0 > If a `--version` flag is defined in `args` or any subcommand, it overrides the built-in versioning. ### Custom validation + ```go var args struct { Foo string @@ -310,13 +337,11 @@ Options: --help, -h display this help and exit ``` - ### Embedded structs The fields of embedded structs are treated just like regular fields: ```go - type DatabaseOptions struct { Host string Username string @@ -384,6 +409,7 @@ func main() { fmt.Printf("%#v\n", args.Name) } ``` + ```shell $ ./example --name=foo.bar main.NameDotName{Head:"foo", Tail:"bar"} @@ -420,6 +446,7 @@ func main() { fmt.Printf("%#v\n", args.Name) } ``` + ```shell $ ./example --help Usage: test [--name NAME] @@ -445,6 +472,7 @@ var args struct { } arg.MustParse(&args) ``` + ```shell $ ./example -h Usage: example [--optimize LEVEL] [--maxjobs N] SRC [DST [DST ...]] @@ -581,7 +609,6 @@ if p.Subcommand() == nil { } ``` - ### Custom handling of --help and --version The following reproduces the internal logic of `MustParse` for the simple case where @@ -722,7 +749,8 @@ func main() { p.WriteUsageForSubcommand(os.Stdout, p.SubcommandNames()...) os.Exit(1) } -}``` +} +``` ```shell $ ./example --version From 51d9bef113c82cff90c5929d28934bb241e1b1df Mon Sep 17 00:00:00 2001 From: Alex Flint Date: Mon, 21 Oct 2024 17:08:37 -0400 Subject: [PATCH 27/27] passing the no-more-options string "--" twice or more should pass the second and subsequent ones through as positionals --- parse.go | 2 +- parse_test.go | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/parse.go b/parse.go index 172c7cd..d9c73d0 100644 --- a/parse.go +++ b/parse.go @@ -615,7 +615,7 @@ func (p *Parser) process(args []string) error { // must use explicit for loop, not range, because we manipulate i inside the loop for i := 0; i < len(args); i++ { arg := args[i] - if arg == "--" { + if arg == "--" && !allpositional { allpositional = true continue } diff --git a/parse_test.go b/parse_test.go index 5bc781c..7e9cbf9 100644 --- a/parse_test.go +++ b/parse_test.go @@ -609,6 +609,15 @@ func TestNoMoreOptionsBeforeHelp(t *testing.T) { assert.NotEqual(t, ErrHelp, err) } +func TestNoMoreOptionsTwice(t *testing.T) { + var args struct { + X []string `arg:"positional"` + } + err := parse("-- --", &args) + require.NoError(t, err) + assert.Equal(t, []string{"--"}, args.X) +} + func TestHelpFlag(t *testing.T) { var args struct { Foo string