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

cmd: introduce printer, helper and new --format flag #151

Merged
merged 7 commits into from
Apr 8, 2021
Merged

Conversation

fatih
Copy link
Member

@fatih fatih commented Apr 7, 2021

This PR changes how we print information and deserialize API resources from the CLI. With that, this PR also adds JSON and CSV formatting options for every single subcommand.

It's a significant change, but it completely changes how we print human-readable information to the user and how we encode resources in various formats, such as JSON or CSV. Here are the important changes:

Updated internal/printer package

This package is completely rewritten. It provides the following new methods to print information:

func (p *Printer) Printf(format string, i ...interface{}) { }
func (p *Printer) Println(i ...interface{}) { }
func (p *Printer) Print(i ...interface{}) { }
func (p *Printer) PrintProgress(message string) func() { }
func (p *Printer) PrintResource(v interface{}) error { }

Instead of the fmt package, we're going to use these commands going forward inside the subcommands to print information to the user. These commands work differently that we have control of the stdout/stderr references. By default, they all print to stdout, but we can easily switch them to /dev/null or anywhere else (such as saving to a file).

The benefit of controlling the output destination comes in handy because we can decide whether the PrintXXX methods are no-op or not. This is especially useful for enabling full JSON mode, where we don't want to output accidentally non-JSON data to stdout (see the issue as an example: https://github.com/planetscale/project-big-bang/issues/197).

How we do dynamically switch of the stdout or disable it? Let me show it with the printer.Print() example (the same logic applies to the other printer.PrintXXX methods):

// Print is a convenience method to Print to the defined output.
func (p *Printer) Print(i ...interface{}) {
	fmt.Fprint(p.out(), i...)
}

// out defines the output to write human readable text. If format is not set to
// human, out returns ioutil.Discard, which means that any output will be
// discarded
func (p *Printer) out() io.Writer {
	if *p.format == Human {
		return os.Stdout
	}

	return ioutil.Discard
}

The important bit is the combination of fmt.Fprint, which allows us to choose the destination to output and the printer.out() io.Writer function that returns the destination. As you see, by default, we return os.Stdout, but that changes to ioutil.Discard, which means to discard the output (think of it like /dev/null).

Now let's move to the printer.Format, which configures the output format and also changes the switch in printer.out()

Changing the output format via printer.Format

In the core of the new printer.Printer type is the newly created printer.Format type:

// Format defines the option output format of a resource.
type Format int

const (
	// Human prints it in human readable format. This can be either a table or
	// a single line, depending on the resource implementation.
	Human Format = iota
	JSON
	CSV
)

// Printer is used to print information to the defined output.
type Printer struct {
	format *Format
}

The printer.Format type is an enum that currently has three values:

  • Human: this is the default value used when you execute pscale. Humans mean human-readable output. This can be either a single line of information, such as Successfully created database 'foo' or a table that lists resources (e.g., a list of databases).
  • JSON: outputs in JSON format.
  • CSV: outputs in CSV format.

This format value is set with the --format and the -f flags and passes as a pointer to the printer.Printer struct:

var format printer.Format
rootCmd.PersistentFlags().VarP(printer.NewFormatValue(printer.Human, &format), "format", "f",
	"Show output in a specific format. Possible values: [human, json, csv]")
if err := viper.BindPFlag("format", rootCmd.PersistentFlags().Lookup("format")); err != nil {
	return err
}

p = printer.NewPrinter(&format)

The value can be later retrieved with the printer.Printer.Format() method and make decisions based on the format. As an example, here is how we use it to print a human friendly message:

if ch.Printer.Format() == printer.Human {
	ch.Printer.Printf("Branch %s was successfully created!\n", printer.BoldBlue(dbBranch.Name))
	return nil
}

return ch.Printer.PrintResource(toDatabaseBranch(dbBranch))

Printing a resource

Printing a piece of information, such as Fetching existing databases... and printing a resource, is not the same. To print an API object, such as a database resource, we need to represent it in serializable data format. The types we receive from the planetscale-go are already serializable, but sometimes we want to control and omit specific fields or annotate them with additional information. Hence each subcommand is responsible for its serialization format. This means that the printer types are now moved into their appropriate subcommand package and no longer a part of the printer package.

To print a resource, such as a branch or list of deploy requests, we use the following printer.PrintResource() function:

// PrintResource prints the given resource in the format it was specified.
func (p *Printer) PrintResource(v interface{}) error {
	if p.format == nil {
		return errors.New("printer.Format is not set")
	}

	switch *p.format {
	case Human:
		var b strings.Builder
		tableprinter.Print(&b, v)
		fmt.Println(b.String())
		return nil
	case JSON:
		out, err := json.MarshalIndent(v, "", "  ")
		if err != nil {
			return err
		}

		fmt.Print(string(out))
		return nil
	case CSV:
		out, err := gocsv.MarshalString(v)
		if err != nil {
			return err
		}
		fmt.Print(out)

		return nil
	}

	return fmt.Errorf("unknown printer.Format: %T", *p.format)
}

The center of the function is again the printer.format field, which decides how we want to serialize and print the given resource. We still use the existing table format for type human, but if the user changes the format via --format json or --format csv, we use the appropriate encoders.

As a side note, we use a custom CSV encoder that can print a struct or a slice of structs. The stdlib encoding/csv requires more work and is not very intuitive to use.

A new helper method for the subcommand

Previously we would pass the *config.Config to each subcommand. This has changed now. To help subcommands facilitate their actions in a deterministic way, we're now passing a helper struct that contains the printer as well:

// Helper is passed to every single command and is used by individual
// subcommands.
type Helper struct {
	// Config contains globally sourced configuration
	Config *config.Config

	// Printer is used to print output of a command to stdout.
	Printer *printer.Printer
}

It's a struct instead of a new argument because we don't want to break existing subcommands when extending with new fields.

Putting everything together

Assuming we have a command that prints the branch status with the following print methods:

func StatusCmd(ch *cmdutil.Helper) *cobra.Command {
	cmd := &cobra.Command{
		Use:   "status <database> <branch>",
		Short: "Check the status of a branch of a database",
		RunE: func(cmd *cobra.Command, args []string) error {
			ctx := context.Background()
			source := args[0]
			branch := args[1]

			client, err := ch.Config.NewClientFromConfig()
			if err != nil {
				return err
			}

			// printing an information to stdout
			ch.Printer.Printf("Getting status for branch %s in %s...", printer.BoldBlue(branch), printer.BoldBlue(source))

			status, err := client.DatabaseBranches.GetStatus(ctx, &planetscale.GetDatabaseBranchStatusRequest{
				Organization: ch.Config.Organization,
				Database:     source,
				Branch:       branch,
			})
			if err != nil {
				return err
			}


			// printing the resource to stdout
			return ch.Printer.PrintResource(toDatabaseBranchStatus(status))
		},
	}

	return cmd
}

Now, here there are two print methods, one being printer.Printf() and the other one is printer.PrintResource. By default, the --format flag is set to printer.Human, which means the output will be in the form:

$ pscale branch status planetscale main 
Getting status for branch main in planetscale...
 STATUS   GATEWAY HOST                                                             GATEWAY PORT   USERNAME   PASSWORD
-------- ------------------------------------------------------------------------ -------------- ---------- ------------------------
 ready    afc03es8ff5a4sc0ead5c4322d4431a9-467843742.us-east-1.elb.amazonaws.com   3306           root       abcdefz7nJxLYA

However, when the user sets the format to --format json, then the printer.PrintXXX() methods will print to ioutil.Discard (which means the Getting status ... message will be discarded) and printer.PrinterResource will print the deserialized resource to stdout:

$ pscale branch status planetscale main --format json
{
  "ready": true,
  "credentials": {
    "mysql_gateway_host": "afc03sc8fd5a44c0faf5c4322s4431a9-467843742.us-east-1.elb.amazonaws.com",
    "mysql_gateway_port": 3306,
    "mysql_gateway_user": "root",
    "mysql_gateway_pass": "abcdefz7nJxLYA"
  }
}

By making sure that all printer.PrintXXX() methods discard their output, we can safely output in JSON format without mangling it with other messages.

closes https://github.com/planetscale/project-big-bang/issues/197
closes https://github.com/planetscale/project-big-bang/issues/168
closes https://github.com/planetscale/project-big-bang/issues/179
closes https://github.com/planetscale/project-big-bang/issues/202
closes https://github.com/planetscale/project-big-bang/issues/201

@fatih fatih marked this pull request as ready for review April 8, 2021 10:23
@fatih fatih requested a review from a team as a code owner April 8, 2021 10:23
Copy link
Contributor

@nickvanw nickvanw left a comment

Choose a reason for hiding this comment

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

There's a lot of changes in here, but most of them are very similar. I've read through and everything looks good to me, but with this much code, I can't certain that everything is perfect. I think that's fine however, it's better to ship this now and get feedback.

The one thing it'd be great to find a solution for would be the end of tons of commands looking like this:

if ch.Printer.Format() == printer.Human {
    ch.Printer.Printf("Schema snapshot %s was successfully created!\n", printer.BoldBlue(snapshot.Name))
    return nil
}

I don't have a better option, but it feels like a bit of a leaky abstraction. I don't think it should hold the PR though!

Comment on lines +55 to 58
if ch.Printer.Format() == printer.Human {
ch.Printer.Printf("Backup %s was successfully created!\n", printer.BoldBlue(bkp.Name))
return nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it make sense to have a pattern for something like:

Suggested change
if ch.Printer.Format() == printer.Human {
ch.Printer.Printf("Backup %s was successfully created!\n", printer.BoldBlue(bkp.Name))
return nil
}
ch.Printer.HumanPrintf("Backup %s was successfully created!\n", printer.BoldBlue(bkp.Name))

That would allow to not have conditionals, and that method could just do nothing if the human printer wasn't configured?

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually, print only works if Human is enabled 🤦🏼‍♂️ So the guards are not needed at all!

Copy link
Member Author

Choose a reason for hiding this comment

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

Hm, now that I think, this will output two things, the message and also the table (because that's enabled for human as well). So in this case, this is how it would look like:

$ pscale branch create planetscale fatih-can-be-deleted
Branch fatih-can-be-deleted was successfully created!
  NAME                   STATUS   PARENT BRANCH   CREATED AT   UPDATED AT   NOTES
 ---------------------- -------- --------------- ------------ ------------ -------
  fatih-can-be-deleted            main            now          now

I thought a lot about this, but not sure what the best idea would be. Happy to improve it after merging this.

Copy link
Contributor

Choose a reason for hiding this comment

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

yeah, I don't really have a great answer. I would merge this now before I put too much thought into it honestly.

@fatih fatih merged commit a1acc53 into main Apr 8, 2021
@fatih fatih deleted the fatih/format-flag branch April 8, 2021 15:04
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.

2 participants