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

v4.0.0-alpha.1 #113

Merged
merged 21 commits into from
Aug 31, 2023
Merged

v4.0.0-alpha.1 #113

merged 21 commits into from
Aug 31, 2023

Conversation

peterbourgon
Copy link
Owner

@peterbourgon peterbourgon commented Jul 20, 2023

This PR introduces v4 of package ff, which includes several enhancements and breaking changes.

What are the major changes?

  • A new Flags interface is defined to represent a flag set
  • A new Flag interface is defined to represent a single flag
  • A getopts-style CoreFlags implementation of the Flags interface is provided
  • (You can still pass a stdlib flag.FlagSet to Parse, in which case it's converted to a CoreFlags)
  • CoreFlags supports the concept of parent flag sets, making it easier to build hierarchical command trees
  • package ffval provides a set of common flag values, and helpers for constructing your own flag values
  • ffcli is merged into ff
  • Command has fewer fields
  • Commands can optionally be Reset, allowing the same command tree to be parsed more than once
  • Usage is refactored to be a free function, taking a command or flag set, and returning a string
  • package ffhelp provides default usage funcs, and helpers for writing your own usage funcs
  • The JSON config file parser is moved to package ffjson
  • The .env file parser is moved to package ffenv
  • fftoml is updated to use pellegier/go-toml@v2

Why move away from flag.FlagSet?

Basically, it was too limited.

While I'm not necessarily sold on all of getopts(3), I think allowing flags to have long (--foo) and short (-f) names is an important feature, and a basic expectation for nearly all users. The fact that the stdlib flag set doesn't support this feature proved to be a problem worth fixing.

Also, using a concrete type kind of paints the module into a corner, in terms of capability and extensibility. I don't think it would have been possible to provide the most-requested features (hierarchical flags, support for different flag set implementations, etc.) without switching to some kind of abstraction.

Why adapt a flag.FlagSet to a bespoke CoreFlags instead of using a separate type?

To take advantage of CoreFlags features like Reset and SetParent, without needing to re-implement them. (I'm not totally convinced this is the right approach, but it's where I've landed for the moment.)

Why model hierarchy as parent flag sets instead of multiple flag sets in a command?

You always have a single set of args provided by the user. If you want to apply those args to multiple flag sets, then you need to find a way for the Parse method of each flag set to consume only the args that map to flags known to the flag set, and yield the args that map to flags that aren't known to the flag set, while also returning non-flag args separately. The command parser would then need to call Parse for each flag set in sequence, passing down the leftover args after each stage.

This would require changes to the Parse method in the Flags interface, which is currently extremely good and intuitive: Parse(args []string) error. Complicating it would be a very tall order, and a large burden for implementations. And after a lot of experimentation, I concluded that it was infeasible to reliably distinguish "a flag that we don't know about" from "a non-flag argument" when parsing args.

Consequently, the design keeps the simpler Parse signature in the Flags interface, and moves all of the logic related to parent flags into the implementation(s). The GetFlags method in the Flag interface was added in part so that callers can know in which flag set a flag was defined.

func New(rootConfig *rootcmd.RootConfig) *CreateConfig {
var cfg CreateConfig
cfg.RootConfig = rootConfig
cfg.Flags = ff.NewFlags("create").SetParent(cfg.RootConfig.Flags)
Copy link
Owner Author

Choose a reason for hiding this comment

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

I don't want to make SetParent part of the Flags interface, because I don't want to dictate that flag sets need to support the concept of parents. At the moment it's a method on the CoreFlags type, and takes a parent CoreFlags value as a parameter. That means that you need to keep track of the concrete CoreFlags value of parent commands somehow, you can't just rely on the Flags field in the parent command directly. At least, not without type assertions. This is annoying, but I'm not sure what a better approach would be.

Comment on lines +14 to +20
// Parse the flag set with the provided args. [Option] values can be used to
// influence parse behavior. For example, options exist to read flags from
// environment variables, config files, etc.
//
// The fs parameter must be of type [Flags] or [*flag.FlagSet]. Any other type
// will result in an error.
func Parse(fs any, args []string, options ...Option) error {
Copy link
Owner Author

Choose a reason for hiding this comment

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

I'm not super happy with fs any but I judge that maintaining support for ff.Parse(<*flag.FlagSet>, ...) is important enough to justify complecting the API in this way. I could be convinced otherwise, especially if there were a better design that I haven't thought of...

Copy link
Owner Author

Choose a reason for hiding this comment

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

I would have loved to use generics to say fs interface{ ff.FlagSet | *flag.FlagSet } — but you can't define a union over anything but concrete types. I guess it makes sense why, but it's unfortunate in this case.

Copy link

@flowchartsman flowchartsman Sep 3, 2023

Choose a reason for hiding this comment

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

I agree that seeing any in a signature where there is actually a runtime constraint is uncomfortable, but (for me at least) that's partially due to the permissive connotation. While we can't (yet) be more specific with general interfaces, you might at least be able to sharpen up the perceptual edges with a type alias like:

// AnyFlagSet must be one of { [ff.FlagSet] | [*flag.FlagSet] }
type AnyFlagSet = any

This at least calls out the restriction in a way that jumps out in the godoc, which could be good enough for now:

go doc Parse
package ff // import "."

func Parse(fs AnyFlagSet, args []string, options ...Option) error
    Parse the flag set with the provided args. Option values can be used to
    influence parse behavior. For example, options exist to read flags from
    environment variables, config files, etc.

    The fs parameter must be of type Flags or *flag.FlagSet. Any other type will
    result in an error.

go doc AnyFlagSet
package ff // import "."

type AnyFlagSet = any
    AnyFlagSet must be one of { ff.FlagSet | *flag.FlagSet }

Choose a reason for hiding this comment

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

Or, alternatively, FlagSetAny, depending on whether you think of it as "an any that's a flagset" or "[the] FlagSet types' any" :D

Copy link
Owner Author

Choose a reason for hiding this comment

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

I like this a lot!

Choose a reason for hiding this comment

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

#115 for your perusal!

Comment on lines +53 to +62
// Flags is the set of flags associated with, and parsed by, this command.
//
// When building a command tree, it's often useful to allow flags defined by
// parent commands to be specified by any subcommand. The core flag set
// supports this behavior via SetParent, see the documentation of that
// method for details.
//
// Optional. If not provided, an empty flag set will be constructed and used
// so that the -h, --help flag works as expected.
Flags Flags
Copy link
Owner Author

Choose a reason for hiding this comment

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

Command.Flags needs to be a true Flags, meaning you can't pass a flag.FlagSet directly, you have to use the adapter constructor. I think this is reasonable: ff.Parse is meant to be symmetrical with flag.Parse, but ff.Command doesn't have any direct analogue in that sense.

Comment on lines +180 to +185
// GetParent returns the parent command of this command, or nil if a parent
// hasn't been set. Parents are set during the parse phase, but only for
// commands which are traversed.
func (cmd *Command) GetParent() *Command {
return cmd.parent
}
Copy link
Owner Author

@peterbourgon peterbourgon Jul 27, 2023

Choose a reason for hiding this comment

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

The intent here is to support usage functions that want to include info from parent commands in the tree.

@peterbourgon peterbourgon marked this pull request as ready for review July 31, 2023 17:03
@mfridman
Copy link

More of a drive-by. Are you looking for feedback on this /v4?

I've used the /v3 fairly extensively in a few projects, so looking forward to whatever iteration comes next.

@peterbourgon
Copy link
Owner Author

Feedback is very much appreciated!

@peterbourgon
Copy link
Owner Author

I'm going to go ahead and release this as v4.0.0-alpha.1 so it can be imported. Feedback still appreciated.

@peterbourgon peterbourgon merged commit 743f646 into main Aug 31, 2023
3 checks passed
@peterbourgon peterbourgon deleted the v4-alpha.1 branch August 31, 2023 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants