Improvements to 'sq version' command (#137)

This commit is contained in:
Neil O'Toole 2022-12-30 10:10:56 -07:00 committed by GitHub
parent 13cfc0e1c8
commit 66a8c39844
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 611 additions and 28 deletions

View File

@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v0.21.0] - 2022-12-30
### Changed
- `sq version` respects `--json` flag.
- `sq version` respects `--verbose` flag. It also shows less info when `-v` is not set.
- `sq version` shows `latest_version` info when `--verbose` and there's a newer version available.
## [v0.20.0] - 2022-12-29
### Added
@ -92,7 +100,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [#89]: Bug with SQL generated for joins.
[v0.19.0]: https://github.com/neilotoole/sq/compare/v0.19.0...v0.20.0
[v0.21.0]: https://github.com/neilotoole/sq/compare/v0.20.0...v0.21.0
[v0.20.0]: https://github.com/neilotoole/sq/compare/v0.19.0...v0.20.0
[v0.19.0]: https://github.com/neilotoole/sq/compare/v0.18.2...v0.19.0
[v0.18.2]: https://github.com/neilotoole/sq/compare/v0.18.0...v0.18.2
[v0.18.0]: https://github.com/neilotoole/sq/compare/v0.17.0...v0.18.0

View File

@ -1,14 +1,22 @@
// Package buildinfo hosts build info variables populated via ldflags.
package buildinfo
// defaultVersion is the default value for Version if not
import (
"fmt"
"strings"
"github.com/neilotoole/sq/libsq/core/stringz"
"golang.org/x/mod/semver"
)
// DefaultVersion is the default value for Version if not
// set via ldflags.
const defaultVersion = "v0.0.0-dev"
const DefaultVersion = "v0.0.0-dev"
var (
// Version is the build version. If not set at build time via
// ldflags, Version takes the value of defaultVersion.
Version = defaultVersion
// ldflags, Version takes the value of DefaultVersion.
Version = DefaultVersion
// Commit is the commit hash.
Commit string
@ -16,3 +24,56 @@ var (
// Timestamp is the timestamp of when the cli was built.
Timestamp string
)
// BuildInfo encapsulates Version, Commit and Timestamp.
type BuildInfo struct {
Version string `json:"version"`
Commit string `json:"commit,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
// String returns a string representation of BuildInfo.
func (bi BuildInfo) String() string {
s := bi.Version
if bi.Commit != "" {
s += " " + bi.Commit
}
if bi.Timestamp != "" {
s += " " + bi.Timestamp
}
return s
}
// Info returns BuildInfo.
func Info() BuildInfo {
return BuildInfo{
Version: Version,
Commit: Commit,
Timestamp: Timestamp,
}
}
func init() { //nolint:gochecknoinits
if strings.HasSuffix(Version, "~dev") {
Version = strings.Replace(Version, "~dev", "-dev", 1)
}
if Version != "" && !semver.IsValid(Version) {
// We want to panic here because it is a pipeline/build failure
// to have an invalid non-empty Version.
panic(fmt.Sprintf("Invalid BuildInfo.Version value: %q", Version))
}
if Timestamp != "" {
// Make sure Timestamp is normalized
t := stringz.TimestampToRFC3339(Timestamp)
if t != "" {
Timestamp = t
}
}
}
// IsDefaultVersion returns true if Version is empty or DefaultVersion.
func IsDefaultVersion() bool {
return Version == "" || Version == DefaultVersion
}

View File

@ -550,11 +550,12 @@ func (rc *RunContext) Close() error {
type writers struct {
fm *output.Formatting
recordw output.RecordWriter
metaw output.MetadataWriter
srcw output.SourceWriter
errw output.ErrorWriter
pingw output.PingWriter
recordw output.RecordWriter
metaw output.MetadataWriter
srcw output.SourceWriter
errw output.ErrorWriter
pingw output.PingWriter
versionw output.VersionWriter
}
// newWriters returns a writers instance configured per defaults and/or
@ -571,12 +572,13 @@ func newWriters(log lg.Log, cmd *cobra.Command, defaults config.Defaults, out, e
// flags and set the various writer fields depending upon which
// writers the format implements.
w = &writers{
fm: fm,
recordw: tablew.NewRecordWriter(out2, fm),
metaw: tablew.NewMetadataWriter(out2, fm),
srcw: tablew.NewSourceWriter(out2, fm),
pingw: tablew.NewPingWriter(out2, fm),
errw: tablew.NewErrorWriter(errOut2, fm),
fm: fm,
recordw: tablew.NewRecordWriter(out2, fm),
metaw: tablew.NewMetadataWriter(out2, fm),
srcw: tablew.NewSourceWriter(out2, fm),
pingw: tablew.NewPingWriter(out2, fm),
errw: tablew.NewErrorWriter(errOut2, fm),
versionw: tablew.NewVersionWriter(out2, fm),
}
// Invoke getFormat to see if the format was specified
@ -589,6 +591,7 @@ func newWriters(log lg.Log, cmd *cobra.Command, defaults config.Defaults, out, e
w.recordw = jsonw.NewStdRecordWriter(out2, fm)
w.metaw = jsonw.NewMetadataWriter(out2, fm)
w.errw = jsonw.NewErrorWriter(log, errOut2, fm)
w.versionw = jsonw.NewVersionWriter(out2, fm)
case config.FormatTable:
// Table is the base format, already set above, no need to do anything.

View File

@ -1,9 +1,17 @@
package cli
import (
"fmt"
"bufio"
"bytes"
"context"
"io"
"net/http"
"strings"
"time"
"github.com/neilotoole/sq/libsq/core/errz"
"github.com/spf13/cobra"
"golang.org/x/mod/semver"
"github.com/neilotoole/sq/cli/buildinfo"
)
@ -15,23 +23,110 @@ func newVersionCmd() *cobra.Command {
RunE: execVersion,
}
cmd.Flags().BoolP(flagJSON, flagJSONShort, false, flagJSONUsage)
return cmd
}
func execVersion(cmd *cobra.Command, args []string) error {
rc := RunContextFrom(cmd.Context())
rc.writers.fm.Hilite.Fprintf(rc.Out, "sq %s", buildinfo.Version)
if len(buildinfo.Commit) > 0 {
fmt.Fprint(rc.Out, " ")
rc.writers.fm.Faint.Fprint(rc.Out, "#"+buildinfo.Commit)
// We'd like to display that there's an update available, but
// we don't want to wait around long for that.
// So, we swallow (but log) any error from the goroutine.
ctx, cancelFn := context.WithTimeout(cmd.Context(), time.Second*2)
defer cancelFn()
resultCh := make(chan string)
go func() {
var err error
v, err := fetchBrewVersion(ctx)
if err != nil {
rc.Log.Error(err)
}
// OK if v is empty
resultCh <- v
}()
var latestVersion string
select {
case <-ctx.Done():
case latestVersion = <-resultCh:
if latestVersion != "" && !strings.HasPrefix(latestVersion, "v") {
latestVersion = "v" + latestVersion
}
}
if len(buildinfo.Timestamp) > 0 {
fmt.Fprint(rc.Out, " ")
rc.writers.fm.Faint.Fprint(rc.Out, buildinfo.Timestamp)
}
fmt.Fprintln(rc.Out)
return nil
return rc.writers.versionw.Version(buildinfo.Info(), latestVersion)
}
func fetchBrewVersion(ctx context.Context) (string, error) {
const u = `https://raw.githubusercontent.com/neilotoole/homebrew-sq/master/sq.rb`
req, err := http.NewRequestWithContext(ctx, "GET", u, http.NoBody)
if err != nil {
return "", errz.Err(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", errz.Wrap(err, "failed to check edgectl brew repo")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if err != nil {
return "", errz.Errorf("failed to check edgectl brew repo: %d %s",
resp.StatusCode, http.StatusText(resp.StatusCode))
}
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", errz.Wrap(err, "failed to read edgectl brew repo body")
}
return getVersionFromBrewFormula(body)
}
// getVersionFromBrewFormula returns the first brew version
// from f, which is a brew ruby formula. The version is returned
// without a "v" prefix, e.g. "0.1.2", not "v0.1.2".
func getVersionFromBrewFormula(f []byte) (string, error) {
var (
line string
val string
err error
)
sc := bufio.NewScanner(bytes.NewReader(f))
for sc.Scan() {
line = sc.Text()
if err = sc.Err(); err != nil {
return "", errz.Err(err)
}
val = strings.TrimSpace(line)
if strings.HasPrefix(val, `version "`) {
// found it
val = val[9:]
val = strings.TrimSuffix(val, `"`)
if !semver.IsValid("v" + val) { // semver pkg requires "v" prefix
return "", errz.Errorf("invalid brew formula: invalid semver")
}
return val, nil
}
if strings.HasPrefix(line, "bottle") {
// Gone too far
return "", errz.New("unable to parse brew formula")
}
}
if sc.Err() != nil {
return "", errz.Wrap(err, "invalid brew formula")
}
return "", errz.New("invalid brew formula")
}

26
cli/cmd_version_test.go Normal file
View File

@ -0,0 +1,26 @@
package cli
import (
"context"
"os"
"testing"
"golang.org/x/mod/semver"
"github.com/stretchr/testify/require"
)
func TestGetVersionFromBrewFormula(t *testing.T) {
f, err := os.ReadFile("testdata/sq-0.20.0.rb")
require.NoError(t, err)
vers, err := getVersionFromBrewFormula(f)
require.NoError(t, err)
require.Equal(t, "0.20.0", vers)
}
func TestFetchBrewVersion(t *testing.T) {
latest, err := fetchBrewVersion(context.Background())
require.NoError(t, err)
require.True(t, semver.IsValid("v"+latest))
}

View File

@ -3,15 +3,42 @@ package jsonw
import (
"bytes"
"fmt"
"io"
"strings"
"github.com/neilotoole/sq/cli/output"
"github.com/neilotoole/sq/cli/output/jsonw/internal"
jcolorenc "github.com/neilotoole/sq/cli/output/jsonw/internal/jcolorenc"
"github.com/neilotoole/sq/libsq/core/errz"
"github.com/neilotoole/sq/libsq/core/sqlz"
)
// writeJSON prints a JSON representation of v to out, using specs
// from fm.
func writeJSON(out io.Writer, fm *output.Formatting, v any) error {
buf := &bytes.Buffer{}
enc := jcolorenc.NewEncoder(buf)
enc.SetColors(internal.NewColors(fm))
enc.SetEscapeHTML(false)
if fm.Pretty {
enc.SetIndent("", fm.Indent)
}
err := enc.Encode(v)
if err != nil {
return errz.Err(err)
}
_, err = fmt.Fprint(out, buf.String())
if err != nil {
return errz.Err(err)
}
return nil
}
// NewStdRecordWriter returns a record writer that outputs each
// record as a JSON object that is an element of JSON array. This is
// to say, standard JSON. For example:

View File

@ -0,0 +1,36 @@
package jsonw
import (
"io"
"github.com/neilotoole/sq/cli/buildinfo"
"github.com/neilotoole/sq/cli/output"
)
var _ output.VersionWriter = (*versionWriter)(nil)
// versionWriter implements output.VersionWriter for JSON.
type versionWriter struct {
out io.Writer
fm *output.Formatting
}
// NewVersionWriter returns a new output.VersionWriter instance
// that outputs version info in JSON.
func NewVersionWriter(out io.Writer, fm *output.Formatting) output.VersionWriter {
return &versionWriter{out: out, fm: fm}
}
func (w *versionWriter) Version(info buildinfo.BuildInfo, latestVersion string) error {
type cliBuildInfo struct {
buildinfo.BuildInfo
LatestVersion string `json:"latest_version"`
}
bi := cliBuildInfo{
BuildInfo: info,
LatestVersion: latestVersion,
}
return writeJSON(w.out, w.fm, bi)
}

View File

@ -0,0 +1,54 @@
package tablew
import (
"fmt"
"io"
"github.com/neilotoole/sq/cli/buildinfo"
"github.com/neilotoole/sq/cli/output"
"golang.org/x/mod/semver"
)
var _ output.VersionWriter = (*versionWriter)(nil)
// versionWriter implements output.VersionWriter for JSON.
type versionWriter struct {
out io.Writer
fm *output.Formatting
}
// NewVersionWriter returns a new output.VersionWriter instance
// that outputs version info in JSON.
func NewVersionWriter(out io.Writer, fm *output.Formatting) output.VersionWriter {
return &versionWriter{out: out, fm: fm}
}
func (w *versionWriter) Version(bi buildinfo.BuildInfo, latestVersion string) error {
fmt.Fprintf(w.out, "sq %s", bi.Version)
// Only print more if --verbose is set.
if !w.fm.Verbose {
fmt.Fprintln(w.out)
return nil
}
if len(bi.Commit) > 0 {
fmt.Fprint(w.out, " ")
w.fm.Faint.Fprint(w.out, "#"+bi.Commit)
}
if len(bi.Timestamp) > 0 {
fmt.Fprint(w.out, " ")
w.fm.Faint.Fprint(w.out, bi.Timestamp)
}
showUpdate := semver.Compare(latestVersion, bi.Version) > 0
if showUpdate {
fmt.Fprint(w.out, " ")
w.fm.Faint.Fprint(w.out, "Update available: ")
w.fm.Number.Fprint(w.out, latestVersion)
}
fmt.Fprintln(w.out)
return nil
}

View File

@ -9,6 +9,7 @@ package output
import (
"time"
"github.com/neilotoole/sq/cli/buildinfo"
"github.com/neilotoole/sq/libsq/core/sqlz"
"github.com/neilotoole/sq/libsq/driver"
"github.com/neilotoole/sq/libsq/source"
@ -81,6 +82,14 @@ type PingWriter interface {
Close() error
}
// VersionWriter prints the CLI version.
type VersionWriter interface {
// Version prints version info. Arg latestVersion is the latest
// version available from the homebrew repository. The value
// may be empty.
Version(info buildinfo.BuildInfo, latestVersion string) error
}
// FlushThreshold is the size in bytes after which a writer
// should flush any internal buffer.
const FlushThreshold = 1000

68
cli/testdata/sq-0.20.0.rb vendored Normal file
View File

@ -0,0 +1,68 @@
# typed: false
# frozen_string_literal: true
# This file was generated by GoReleaser. DO NOT EDIT.
class Sq < Formula
desc "sq: swiss-army knife for data"
homepage "https://github.com/neilotoole/sq"
version "0.20.0"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-macos-arm64.tar.gz"
sha256 "3804a2cbb7789df4d0008af45dea59969a932b242ec7f9e18c71deeb4132d4b5"
def install
bin.install "sq"
bash_completion.install "completions/sq.bash" => "sq"
zsh_completion.install "completions/sq.zsh" => "_sq"
fish_completion.install "completions/sq.fish"
man1.install "manpages/sq.1.gz"
end
end
if Hardware::CPU.intel?
url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-macos-amd64.tar.gz"
sha256 "5674316e5a8efd4430a30679cf5129e5c2e9966e30d294ba0901266ce8c9e212"
def install
bin.install "sq"
bash_completion.install "completions/sq.bash" => "sq"
zsh_completion.install "completions/sq.zsh" => "_sq"
fish_completion.install "completions/sq.fish"
man1.install "manpages/sq.1.gz"
end
end
end
on_linux do
if Hardware::CPU.arm? && Hardware::CPU.is_64_bit?
url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-linux-arm64.tar.gz"
sha256 "0769b928a202cf0c7174b6e0fb4f086375600bba333e8bbc2ec45a8b8d43464d"
def install
bin.install "sq"
bash_completion.install "completions/sq.bash" => "sq"
zsh_completion.install "completions/sq.zsh" => "_sq"
fish_completion.install "completions/sq.fish"
man1.install "manpages/sq.1.gz"
end
end
if Hardware::CPU.intel?
url "https://github.com/neilotoole/sq/releases/download/v0.20.0/sq-0.20.0-linux-amd64.tar.gz"
sha256 "38263083d09dc5c0f5dd1d3cf24daac90fa7b013c865be7c9192fe7eca439486"
def install
bin.install "sq"
bash_completion.install "completions/sq.bash" => "sq"
zsh_completion.install "completions/sq.zsh" => "_sq"
fish_completion.install "completions/sq.fish"
man1.install "manpages/sq.1.gz"
end
end
end
test do
system "#{bin}/sq --version"
end
end

View File

@ -402,3 +402,131 @@ func TrimLen(s string, maxLen int) string {
return s[:maxLen]
}
const (
// RFC3339Milli is an RFC3339 format with millisecond precision.
RFC3339Milli = "2006-01-02T15:04:05.000Z07:00"
// RFC3339MilliZulu is the same as RFC3339Milli, but in zulu time.
RFC3339MilliZulu = "2006-01-02T15:04:05.000Z"
// rfc3339variant is a variant using "-0700" suffix.
rfc3339variant = "2006-01-02T15:04:05-0700"
// RFC3339Zulu is an RFC3339 format, in Zulu time.
RFC3339Zulu = "2006-01-02T15:04:05Z"
// ISO8601 is similar to RFC3339Milli, but doesn't have the colon
// in the timezone offset.
ISO8601 = "2006-01-02T15:04:05.000Z0700"
// DateOnly is a date-only format.
DateOnly = "2006-01-02"
)
// TimestampUTC returns the RFC3339Milli representation of t in UTC.
func TimestampUTC(t time.Time) string {
return t.UTC().Format(RFC3339Milli)
}
// DateUTC returns a date representation (2020-10-31) of t in UTC.
func DateUTC(t time.Time) string {
return t.UTC().Format(DateOnly)
}
// TimestampToRFC3339 takes a RFC3339Milli, ISO8601 or RFC3339
// timestamp, and returns RFC3339. That is, the milliseconds are dropped.
// On error, the empty string is returned.
func TimestampToRFC3339(s string) string {
t, err := ParseTimestampUTC(s)
if err != nil {
return ""
}
return t.UTC().Format(RFC3339Zulu)
}
// TimestampToDate takes a RFC3339Milli, ISO8601 or RFC3339
// timestamp, and returns just the date component.
// On error, the empty string is returned.
func TimestampToDate(s string) string {
t, err := ParseTimestampUTC(s)
if err != nil {
return ""
}
return t.UTC().Format(DateOnly)
}
// ParseTimestampUTC is the counterpart of TimestampUTC. It attempts
// to parse s first in RFC3339Milli, then time.RFC3339 format, falling
// back to the subtly different ISO8601 format.
func ParseTimestampUTC(s string) (time.Time, error) {
t, err := time.Parse(RFC3339Milli, s)
if err == nil {
return t.UTC(), nil
}
// Fallback to RFC3339
t, err = time.Parse(time.RFC3339, s)
if err == nil {
return t.UTC(), nil
}
// Fallback to ISO8601
t, err = time.Parse(ISO8601, s)
if err == nil {
return t.UTC(), nil
}
t, err = time.Parse(rfc3339variant, s)
if err == nil {
return t.UTC(), nil
}
return time.Time{}, errz.Errorf("failed to parse timestamp {%s}", s)
}
// ParseLocalDate accepts a date string s, returning the local midnight
// time of that date. Arg s must in format "2006-01-02".
func ParseLocalDate(s string) (time.Time, error) {
if !strings.ContainsRune(s, 'T') {
// It's a date
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return t, err
}
return t, nil
}
// There's a 'T' in s, which means its probably a timestamp.
return time.Time{}, errz.Errorf("invalid date format: %s", s)
}
// ParseUTCDate accepts a date string s, returning the UTC midnight
// time of that date. Arg s must in format "2006-01-02".
func ParseUTCDate(s string) (time.Time, error) {
if !strings.ContainsRune(s, 'T') {
// It's a date
t, err := time.ParseInLocation("2006-01-02", s, time.UTC)
if err != nil {
return t, err
}
return t, nil
}
// There's a 'T' in s, which means its probably a timestamp.
return time.Time{}, errz.Errorf("invalid date format: %s", s)
}
// ParseDateOrTimestampUTC attempts to parse s as either
// a date (see ParseUTCDate), or timestamp (see ParseTimestampUTC).
// The returned time is in UTC.
func ParseDateOrTimestampUTC(s string) (time.Time, error) {
if strings.ContainsRune(s, 'T') {
return ParseTimestampUTC(s)
}
t, err := ParseUTCDate(s)
return t.UTC(), err
}

View File

@ -3,6 +3,7 @@ package stringz_test
import (
"strings"
"testing"
"time"
"github.com/neilotoole/sq/testh/tutil"
@ -303,3 +304,54 @@ func TestLineCount(t *testing.T) {
})
}
}
func TestTimestampUTC(t *testing.T) {
tm := time.Date(2021, 0o1, 0o1, 7, 7, 7, 0, time.UTC)
s := stringz.TimestampUTC(tm)
t.Log(s)
require.Equal(t, "2021-01-01T07:07:07.000Z", s)
s = stringz.TimestampUTC(time.Now().UTC())
t.Log(s)
}
func TestParseDateOrTimestampUTC(t *testing.T) {
testCases := []struct {
in string
want int64
wantErr bool
}{
{in: "", wantErr: true},
{in: "not_a_time", wantErr: true},
{in: "2021-01-16T18:18:49.348-0700", want: 1610846329},
{in: "2021-01-16T18:26:39.216-07:00", want: 1610846799},
{in: "2021-01-16T18:26:39-07:00", want: 1610846799},
{in: "2021-01-17T01:26:39.216Z", want: 1610846799},
{in: "2021-01-17", want: 1610841600},
}
for i, tc := range testCases {
tc := tc
t.Run(tutil.Name(i, tc.in), func(t *testing.T) {
tm, err := stringz.ParseDateOrTimestampUTC(tc.in)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
t.Logf("Got: %s", stringz.TimestampUTC(tm))
ut := tm.Unix()
require.Equal(t, tc.want, ut)
})
}
}
func TestZuluTimestamp(t *testing.T) {
const (
input = `2022-12-30T09:36:31-0700`
want = `2022-12-30T16:36:31Z`
)
got := stringz.TimestampToRFC3339(input)
require.Equal(t, want, got)
}

15
testh/post-install-macos.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/sh
# ".completions.sh regenerates completions to "./completions".
set +e
brew uninstall neilotoole/sq/sq
if [[ $(which sq) ]]; then
echo "sq is still present"
rm $(which sq)
fi
set -e
brew install neilotoole/sq/sq
# TODO: Test the version