Skip to content

feat(core): add support for relative date parsing #1366

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

Merged
merged 13 commits into from
Sep 11, 2020
45 changes: 45 additions & 0 deletions cmd/scw/testdata/test-all-usage-help-date-usage.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
🎲🎲🎲 EXIT CODE: 0 🎲🎲🎲
🟥🟥🟥 STDERR️️ 🟥🟥🟥️
Date parsing

You have two ways for managing date in the CLI: Absolute and Relative

- Absolute time

Absolute time refers to a specific and absolute point in time.
CLI uses RFC3339 to parse those time and pass a time.Time go structure to the underlying functions.

Example: "2006-01-02T15:04:05Z07:00"

- Relative time

Relative time refers to a time calculated from adding a given duration to the time when a command is launched.

Example:
- +1d4m => current time plus 1 day and 4 minutes
- -1d4m => current time minus 1 day and 4 minutes

- Units of time

Nanosecond: ns
Microsecond: us, µs (U+00B5 = micro symbol), μs (U+03BC = Greek letter mu)
Millisecond: ms
Second: s, sec, second, seconds
Minute: m, min, minute, minutes
Hour: h, hr, hour, hours
Day: d, day, days
Week: w, wk, week, weeks
Month: mo, mon, month, months
Year: y, yr, year, years

USAGE:
scw help date

FLAGS:
-h, --help help for date

GLOBAL FLAGS:
-c, --config string The path to the config file
-D, --debug Enable debug mode
-o, --output string Output format: json or human, see 'scw help output' for more info (default "human")
-p, --profile string The config profile to use
1 change: 1 addition & 0 deletions cmd/scw/testdata/test-all-usage-help-usage.golden
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ USAGE:
scw help <command>

AVAILABLE COMMANDS:
date Get help about how date parsing works in the CLI
output Get help about how the CLI output works

FLAGS:
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/getsentry/raven-go v0.2.0
github.com/gorilla/websocket v1.4.2
github.com/hashicorp/go-version v1.2.0
github.com/karrick/tparse v2.4.2+incompatible
github.com/kr/pretty v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.4
github.com/mattn/go-isatty v0.0.11
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/karrick/tparse v1.0.0 h1:qVJoscl1sG/UodDmNjjY6cSIun7s541PNNE42dqstfg=
github.com/karrick/tparse v2.4.2+incompatible h1:+cW306qKAzrASC5XieHkgN7/vPaGKIuK62Q7nI7DIRc=
github.com/karrick/tparse v2.4.2+incompatible/go.mod h1:ASPA+vrIcN1uEW6BZg8vfWbzm69ODPSYZPU6qJyfdK0=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
Expand Down
18 changes: 18 additions & 0 deletions internal/args/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,21 @@ func missingIndices(index, length int) string {
}
return strings.Join(s, ",")
}

type CannotParseDateError struct {
ArgValue string
AbsoluteTimeParseError error
RelativeTimeParseError error
}

func (e *CannotParseDateError) Error() string {
return fmt.Sprintf(`date parsing error: could not parse %s`, e.ArgValue)
}

type CannotParseBoolError struct {
Value string
}

func (e *CannotParseBoolError) Error() string {
return "invalid boolean value"
}
34 changes: 28 additions & 6 deletions internal/args/unmarshal.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"time"

"github.com/dustin/go-humanize"
"github.com/karrick/tparse"
"github.com/scaleway/scaleway-sdk-go/scw"
"github.com/scaleway/scaleway-sdk-go/strcase"
"github.com/scaleway/scaleway-sdk-go/validation"
Expand All @@ -25,6 +26,8 @@ type Unmarshaler interface {

type UnmarshalFunc func(value string, dest interface{}) error

var TestForceNow *time.Time

var unmarshalFuncs = map[reflect.Type]UnmarshalFunc{
reflect.TypeOf((*scw.Size)(nil)).Elem(): func(value string, dest interface{}) error {
// Only support G, GB for now (case insensitive).
Expand Down Expand Up @@ -61,13 +64,32 @@ var unmarshalFuncs = map[reflect.Type]UnmarshalFunc{

reflect.TypeOf((*time.Time)(nil)).Elem(): func(value string, dest interface{}) error {
// Handle absolute time
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return err
absoluteTimeParsed, absoluteErr := time.Parse(time.RFC3339, value)
if absoluteErr == nil {
*(dest.(*time.Time)) = absoluteTimeParsed
return nil
}

*(dest.(*time.Time)) = t
return nil
// Handle relative time
if value[0] != '+' && value[0] != '-' {
value = "+" + value
}
m := map[string]time.Time{
"t": time.Now(),
}
if TestForceNow != nil {
m["t"] = *TestForceNow
}
relativeTimeParsed, relativeErr := tparse.ParseWithMap(time.RFC3339, "t"+value, m)
if relativeErr == nil {
*(dest.(*time.Time)) = relativeTimeParsed
return nil
}
return &CannotParseDateError{
ArgValue: value,
AbsoluteTimeParseError: absoluteErr,
RelativeTimeParseError: relativeErr,
}
},
}

Expand Down Expand Up @@ -370,7 +392,7 @@ func unmarshalScalar(value string, dest reflect.Value) error {
case "false":
dest.SetBool(false)
default:
return fmt.Errorf("invalid boolean value")
return &CannotParseBoolError{Value: value}
}
return nil
case reflect.String:
Expand Down
30 changes: 30 additions & 0 deletions internal/args/unmarshal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import (
"github.com/stretchr/testify/assert"
)

func init() {
TestForceNow = scw.TimePtr(time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))
}

func TestUnmarshalStruct(t *testing.T) {
type TestCase struct {
args []string
Expand Down Expand Up @@ -247,6 +251,32 @@ func TestUnmarshalStruct(t *testing.T) {
},
}))

t.Run("Relative date positive", run(TestCase{
args: []string{
"time=+1m1s",
},
expected: &WellKnownTypes{
Time: time.Date(1970, 01, 01, 0, 1, 1, 0, time.UTC),
},
}))

t.Run("Relative date negative", run(TestCase{
args: []string{
"time=-1m1s",
},
expected: &WellKnownTypes{
Time: time.Date(1969, 12, 31, 23, 58, 59, 0, time.UTC),
},
}))

t.Run("Unknown relative date markers", run(TestCase{
data: &time.Time{},
args: []string{
"time=-1R",
},
error: `cannot unmarshal arg 'time=-1R': cannot set nested field for unmarshalable type time.Time`,
}))

t.Run("nested-basic", run(TestCase{
args: []string{
"basic.string=test",
Expand Down
29 changes: 21 additions & 8 deletions internal/core/cobra_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,27 @@ func handleUnmarshalErrors(cmd *Command, unmarshalErr *args.UnmarshalArgError) e

switch e := wrappedErr.(type) {
case *args.CannotUnmarshalError:
hint := ""
if _, ok := e.Dest.(*bool); ok {
hint = "Possible values: true, false"
}

return &CliError{
Err: fmt.Errorf("invalid value for '%s' argument: %s", unmarshalErr.ArgName, e.Err),
Hint: hint,
switch e.Err.(type) {
case *args.CannotParseBoolError:
return &CliError{
Err: fmt.Errorf(""),
Message: fmt.Sprintf("invalid value for '%s' argument: invalid boolean value", unmarshalErr.ArgName),
Hint: "Possible values: true, false",
}
case *args.CannotParseDateError:
dateErr := e.Err.(*args.CannotParseDateError)
return &CliError{
Err: fmt.Errorf("date parsing error: %s", dateErr.ArgValue),
Message: fmt.Sprintf("could not parse %s as either an absolute time (RFC3339) nor a relative time (+/-)RFC3339", dateErr.ArgValue),
Details: fmt.Sprintf(`Absolute time error: %s
Relative time error: %s
`, dateErr.AbsoluteTimeParseError, dateErr.RelativeTimeParseError),
Hint: "Run `scw help date` to learn more about date parsing",
}
default:
return &CliError{
Err: fmt.Errorf("invalid value for '%s' argument: %s", unmarshalErr.ArgName, e.Err),
}
}

case *args.UnknownArgError, *args.InvalidArgNameError:
Expand Down
31 changes: 31 additions & 0 deletions internal/core/cobra_utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"reflect"
"testing"
"time"

"github.com/scaleway/scaleway-cli/internal/args"
)
Expand All @@ -14,6 +15,10 @@ type testType struct {
Tag string
}

type testDate struct {
Date *time.Time
}

func testGetCommands() *Commands {
return NewCommands(
&Command{
Expand Down Expand Up @@ -64,6 +69,16 @@ func testGetCommands() *Commands {
return res, nil
},
},
&Command{
Namespace: "test",
Resource: "date",
ArgsType: reflect.TypeOf(testDate{}),
AllowAnonymousClient: true,
Run: func(ctx context.Context, argsI interface{}) (i interface{}, e error) {
a := argsI.(*testDate)
return a.Date, nil
},
},
)
}

Expand Down Expand Up @@ -91,6 +106,22 @@ func Test_handleUnmarshalErrors(t *testing.T) {
}),
),
}))

t.Run("relative date", Test(&TestConfig{
Commands: testGetCommands(),
Cmd: "scw test date date=+3R",
Check: TestCheckCombine(
TestCheckExitCode(1),
TestCheckError(&CliError{
Message: "could not parse +3R as either an absolute time (RFC3339) nor a relative time (+/-)RFC3339",
Details: `Absolute time error: parsing time "+3R" as "2006-01-02T15:04:05Z07:00": cannot parse "+3R" as "2006"
Relative time error: unknown unit in duration: "R"
`,
Err: fmt.Errorf("date parsing error: +3R"),
Hint: "Run `scw help date` to learn more about date parsing",
}),
),
}))
}

func Test_RawArgs(t *testing.T) {
Expand Down
4 changes: 4 additions & 0 deletions internal/core/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/dnaeon/go-vcr/cassette"
"github.com/dnaeon/go-vcr/recorder"
"github.com/hashicorp/go-version"
args "github.com/scaleway/scaleway-cli/internal/args"
"github.com/scaleway/scaleway-cli/internal/human"
"github.com/scaleway/scaleway-cli/internal/interactive"
"github.com/scaleway/scaleway-sdk-go/api/test/v1"
Expand Down Expand Up @@ -284,6 +285,9 @@ func Test(config *TestConfig) func(t *testing.T) {
testLogger.level = logger.LogLevelDebug
}

// We need to set up this variable to ensure that relative date parsing stay consistent
args.TestForceNow = scw.TimePtr(time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))

// Because human marshal of date is relative (e.g 3 minutes ago) we must make sure it stay consistent for golden to works.
// Here we return a constant string. We may need to find a better place to put this.
human.RegisterMarshalerFunc(time.Time{}, func(i interface{}, opt *human.MarshalOpt) (string, error) {
Expand Down
1 change: 1 addition & 0 deletions internal/namespaces/help/custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func GetCommands() *core.Commands {
return core.NewCommands(
helpRoot(),
newHelpCommand("output", shortOutput, longOutput),
newHelpCommand("date", shortDate, longDate),
)
}

Expand Down
37 changes: 37 additions & 0 deletions internal/namespaces/help/date.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package help

const (
shortDate = "Get help about how date parsing works in the CLI"
longDate = `Date parsing

You have two ways for managing date in the CLI: Absolute and Relative

- Absolute time

Absolute time refers to a specific and absolute point in time.
CLI uses RFC3339 to parse those time and pass a time.Time go structure to the underlying functions.

Example: "2006-01-02T15:04:05Z07:00"

- Relative time

Relative time refers to a time calculated from adding a given duration to the time when a command is launched.

Example:
- +1d4m => current time plus 1 day and 4 minutes
- -1d4m => current time minus 1 day and 4 minutes

- Units of time

Nanosecond: ns
Microsecond: us, µs (U+00B5 = micro symbol), μs (U+03BC = Greek letter mu)
Millisecond: ms
Second: s, sec, second, seconds
Minute: m, min, minute, minutes
Hour: h, hr, hour, hours
Day: d, day, days
Week: w, wk, week, weeks
Month: mo, mon, month, months
Year: y, yr, year, years
`
)