Repository: urfave/cli
Pull request #1432 (2022-07-09 19:27:16)

accept timezone for timestamps
## What type of PR is this?

- feature

## What this PR does / why we need it:

When `TimestampFlag` is used with implicit timezone, default `time.Parse` behavior is assumed, which is parsing time in some timezone, and provide a UTC time. I haven't tested it myself, but I've read somewhere this works just with local timezone on windows, but it does this in UTC on linux. Either way, `time.Parse` assumes UTC times on linux and mac.

The suggested solution is to use `time.ParseInLocation` instead, and provide a `*time.Location` value for the default timezone.

This PR adds a `Timezone` member into `TimestampFlag` struct. Input parsing runs `time.ParseInLocation` when location is present, but keeps old behavior if Timezone is nil, to guarantee backwards compatibility.

## Which issue(s) this PR fixes:

I haven't opened a separate issue for this PR.

## Special notes for your reviewer:

I haven't included v2approve output.

## Testing

This PR adds a new test, TestTimestampFlagApply_Timezoned (copied from TestTimestampFlagApply), which constructs a static timezone called PDT (UTC-7h), and expects the example UTC time provided in PDT timezone.

## Release Notes

```release-note
Add optional timezone (*time.Location) to TimezoneFlag to assume other timezones than UTC
```



Patch:
diff --git a/docs/v2/manual.md b/docs/v2/manual.md
index ebff418..475e047 100644
--- a/docs/v2/manual.md
+++ b/docs/v2/manual.md
@@ -1413,6 +1413,19 @@ In this example the flag could be used like this:
 $ myapp --meeting 2019-08-12T15:04:05
 ```
 
+When the layout doesn't contain timezones, timestamp will render with UTC. To
+change behavior, a default timezone can be provided with flag definition:
+
+```go
+app := &cli.App{
+	Flags: []cli.Flag{
+		&cli.TimestampFlag{Name: "meeting", Layout: "2006-01-02T15:04:05", Timezone: time.Local},
+	},
+}
+```
+
+(time.Local contains the system's local time zone.)
+
 Side note: quotes may be necessary around the date depending on your layout (if
 you have spaces for instance)
 
diff --git a/flag-spec.yaml b/flag-spec.yaml
index d85fa30..45f054d 100644
--- a/flag-spec.yaml
+++ b/flag-spec.yaml
@@ -43,6 +43,7 @@ flag_types:
     value_pointer: true
     struct_fields:
       - { name: Layout, type: string }
+      - { name: Timezone, type: "*time.Location" }
 
   # TODO: enable UintSlice
   # UintSlice: {}
diff --git a/flag_timestamp.go b/flag_timestamp.go
index 0522477..80e1f47 100644
--- a/flag_timestamp.go
+++ b/flag_timestamp.go
@@ -11,6 +11,7 @@ type Timestamp struct {
 	timestamp  *time.Time
 	hasBeenSet bool
 	layout     string
+	location   *time.Location
 }
 
 // Timestamp constructor
@@ -31,9 +32,22 @@ func (t *Timestamp) SetLayout(layout string) {
 	t.layout = layout
 }
 
+// Set perceived timezone of the to-be parsed time string
+func (t *Timestamp) SetLocation(loc *time.Location) {
+	t.location = loc
+}
+
 // Parses the string value to timestamp
 func (t *Timestamp) Set(value string) error {
-	timestamp, err := time.Parse(t.layout, value)
+	var timestamp time.Time
+	var err error
+
+	if t.location != nil {
+		timestamp, err = time.ParseInLocation(t.layout, value, t.location)
+	} else {
+		timestamp, err = time.Parse(t.layout, value)
+	}
+
 	if err != nil {
 		return err
 	}
@@ -104,9 +118,11 @@ func (f *TimestampFlag) Apply(set *flag.FlagSet) error {
 		f.Value = &Timestamp{}
 	}
 	f.Value.SetLayout(f.Layout)
+	f.Value.SetLocation(f.Timezone)
 
 	if f.Destination != nil {
 		f.Destination.SetLayout(f.Layout)
+		f.Destination.SetLocation(f.Timezone)
 	}
 
 	if val, source, found := flagFromEnvOrFile(f.EnvVars, f.FilePath); found {
diff --git a/godoc-current.txt b/godoc-current.txt
index a3a7fac..1ba48cc 100644
--- a/godoc-current.txt
+++ b/godoc-current.txt
@@ -269,6 +269,9 @@ type App struct {
 	Version string
 	// Description of the program
 	Description string
+	// DefaultCommand is the (optional) name of a command
+	// to run if no command names are passed as CLI arguments.
+	DefaultCommand string
 	// List of commands to execute
 	Commands []*Command
 	// List of flags to parse
@@ -1754,6 +1757,9 @@ func (t *Timestamp) Set(value string) error
 func (t *Timestamp) SetLayout(layout string)
     Set the timestamp string layout for future parsing
 
+func (t *Timestamp) SetLocation(loc *time.Location)
+    Set perceived timezone of the to-be parsed time string
+
 func (t *Timestamp) SetTimestamp(value time.Time)
     Set the timestamp value directly
 
@@ -1782,6 +1788,8 @@ type TimestampFlag struct {
 	EnvVars []string
 
 	Layout string
+
+	Timezone *time.Location
 }
     TimestampFlag is a flag with type *Timestamp
 
diff --git a/zz_generated.flags.go b/zz_generated.flags.go
index 3cae978..b89566f 100644
--- a/zz_generated.flags.go
+++ b/zz_generated.flags.go
@@ -280,6 +280,8 @@ type TimestampFlag struct {
 	EnvVars []string
 
 	Layout string
+
+	Timezone *time.Location
 }
 
 // String returns a readable representation of this value (for usage defaults)


Test patch:
diff --git a/flag_test.go b/flag_test.go
index 0bb893a..e46b1ef 100644
--- a/flag_test.go
+++ b/flag_test.go
@@ -2364,6 +2364,18 @@ func TestTimestampFlagApply_Fail_Parse_Wrong_Time(t *testing.T) {
 	expect(t, err, fmt.Errorf("invalid value \"2006-01-02T15:04:05Z\" for flag -time: parsing time \"2006-01-02T15:04:05Z\" as \"Jan 2, 2006 at 3:04pm (MST)\": cannot parse \"2006-01-02T15:04:05Z\" as \"Jan\""))
 }
 
+func TestTimestampFlagApply_Timezoned(t *testing.T) {
+	pdt := time.FixedZone("PDT", -7*60*60)
+	expectedResult, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z")
+	fl := TimestampFlag{Name: "time", Aliases: []string{"t"}, Layout: time.ANSIC, Timezone: pdt}
+	set := flag.NewFlagSet("test", 0)
+	_ = fl.Apply(set)
+
+	err := set.Parse([]string{"--time", "Mon Jan 2 08:04:05 2006"})
+	expect(t, err, nil)
+	expect(t, *fl.Value.timestamp, expectedResult.In(pdt))
+}
+
 func TestTimestampFlagValueFromContext(t *testing.T) {
 	set := flag.NewFlagSet("test", 0)
 	now := time.Now()