Repository: google/starlark-go
Pull request #327 (2020-12-04 17:39:56)

time: add time module
Signed-off-by: b5 <sparkle_pony_2000@qri.io>

A few notes:
* I've added this to `lib/time` based on @alandonovan's feedback from #326
* we're waiting on a chat with @srebhan about the naming of the constructors vs. parsing functions before this is final
* We use a [tool](https://github.com/b5/outline) to extract documentation from this package in `doc.go` into other formats. I can remove this to conform to other packages, but if this info can stay & be maintained, I'd be deeply appreciated. Keeping documentation to the code itself has made our lives much easier.
* If we merge this I'll take this back to our contributors on the starlib project & chat about relying on this package upstream. @alandonovan in the  past you'd mentioned interest in other packages from the project, now might be the time to go shopping 😉


google-cla[bot]: 
Thanks for your pull request. It looks like this may be your first contribution to a Google open source project (if not, look below for help). Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

:memo: **Please visit <https://cla.developers.google.com/> to sign.**

Once you've signed (or fixed any issues), please reply here with `@googlebot I signed it!` and we'll verify it.

----

#### What to do if you already signed the CLA

##### Individual signers

*   It's possible we don't have your GitHub username or you're using a different email address on your commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).

##### Corporate signers

*   Your company has a Point of Contact who decides which employees are authorized to participate. Ask your POC to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the Google project maintainer to [go/cla#troubleshoot](http://go/cla#troubleshoot) ([Public version](https://opensource.google/docs/cla/#troubleshoot)).
*   The email used to register you as an authorized contributor must be the email used for the Git commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
*   The email used to register you as an authorized contributor must also be [attached to your GitHub account](https://github.com/settings/emails).
		

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fstarlark-go%2Fpull%2F327) for more info**.

<!-- need_sender_cla -->
b5: @googlebot I signed it!
google-cla[bot]: 
Thanks for your pull request. It looks like this may be your first contribution to a Google open source project (if not, look below for help). Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

:memo: **Please visit <https://cla.developers.google.com/> to sign.**

Once you've signed (or fixed any issues), please reply here with `@googlebot I signed it!` and we'll verify it.

----

#### What to do if you already signed the CLA

##### Individual signers

*   It's possible we don't have your GitHub username or you're using a different email address on your commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).

##### Corporate signers

*   Your company has a Point of Contact who decides which employees are authorized to participate. Ask your POC to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the Google project maintainer to [go/cla#troubleshoot](http://go/cla#troubleshoot) ([Public version](https://opensource.google/docs/cla/#troubleshoot)).
*   The email used to register you as an authorized contributor must be the email used for the Git commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
*   The email used to register you as an authorized contributor must also be [attached to your GitHub account](https://github.com/settings/emails).
		

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fstarlark-go%2Fpull%2F327) for more info**.

<!-- need_sender_cla -->
srebhan: @b5: Thanks for your quick reaction! I have `duration()` and `time()` as "constructors" and use `parse_{time,duration}()` as parsing function. I think having the "class-name" as constructors is a matter of least-surprise and the the name for the parsing functions are obvious. However, I see that this would crush your current API...
Is there anything I can do to speed this up?

@alandonovan: I'm supporting this PR and will close mine once this one is merged.
google-cla[bot]: 
Thanks for your pull request. It looks like this may be your first contribution to a Google open source project (if not, look below for help). Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

:memo: **Please visit <https://cla.developers.google.com/> to sign.**

Once you've signed (or fixed any issues), please reply here with `@googlebot I signed it!` and we'll verify it.

----

#### What to do if you already signed the CLA

##### Individual signers

*   It's possible we don't have your GitHub username or you're using a different email address on your commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).

##### Corporate signers

*   Your company has a Point of Contact who decides which employees are authorized to participate. Ask your POC to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the Google project maintainer to [go/cla#troubleshoot](http://go/cla#troubleshoot) ([Public version](https://opensource.google/docs/cla/#troubleshoot)).
*   The email used to register you as an authorized contributor must be the email used for the Git commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
*   The email used to register you as an authorized contributor must also be [attached to your GitHub account](https://github.com/settings/emails).
		

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fstarlark-go%2Fpull%2F327) for more info**.

<!-- need_sender_cla -->
srebhan: @b5 any news on this one? I'm eagerly waiting for time support! ;-P

google-cla[bot]: 
Thanks for your pull request. It looks like this may be your first contribution to a Google open source project (if not, look below for help). Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

:memo: **Please visit <https://cla.developers.google.com/> to sign.**

Once you've signed (or fixed any issues), please reply here with `@googlebot I signed it!` and we'll verify it.

----

#### What to do if you already signed the CLA

##### Individual signers

*   It's possible we don't have your GitHub username or you're using a different email address on your commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).

##### Corporate signers

*   Your company has a Point of Contact who decides which employees are authorized to participate. Ask your POC to be added to the group of authorized contributors. If you don't know who your Point of Contact is, direct the Google project maintainer to [go/cla#troubleshoot](http://go/cla#troubleshoot) ([Public version](https://opensource.google/docs/cla/#troubleshoot)).
*   The email used to register you as an authorized contributor must be the email used for the Git commit. Check [your existing CLA data](https://cla.developers.google.com/clas) and verify that your [email is set on your git commits](https://help.github.com/articles/setting-your-email-in-git/).
*   The email used to register you as an authorized contributor must also be [attached to your GitHub account](https://github.com/settings/emails).
		

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fstarlark-go%2Fpull%2F327) for more info**.

<!-- need_sender_cla -->
b5: hi all, 

I still have a little more cleanup to do here, but wanted to get this set of changes up for review. I've made a bunch of adjustments to the Starlark API based on review feedback.

@srebhan, what do you think the signature for the `time.time` constructor should be? I've currently got it wrapping golang's `time.Date`, but could also see removing `time.from_timestamp` and using that instead.
srebhan: @b5 I had `time([year[, month[, day[, hour[, minute[, second[, nanosecond[, location]]]]]]]])` to mimic the python API a bit, but I think a `kwargs` approach also works. Whatever you prefer to be honest! :-)

srebhan: @b5 thanks for all your work! Walked through the code once again, but IMO we are very close. However I've no say here. ;-)
b5: @srebhan I know you'd like to get this merged sooner rather than later, and I've got a number of other projects on the go at the moment. I've invited you as a collaborator on our fork, would you mind taking over this branch & making the necessary changes to get this up to speed? If not I'll get these changes from review made, but I can't guarantee they'll be done quickly.
srebhan: @b5 I talked to my management and they were clear that my mandate only covers contributions to this repository. :-( So I think getting permission to contribute to your repo will take more time than it saves...
This being said, I'm allowed to make suggestion in the comments and you can accept them. Would this be an option?
Alternatively, we might ask @adonovan what is absolutely necessary to get this merged and then I will fix the remaining issues with an additional PR!?
b5: ah that makes sense. I'll do what I can to make changes from review as soon as I can. Given that we can't directly collaborate, I think it'll be easier to just add another hour or two to clean this up & see if we can't get it merged. I'll try to get find that hour in the next day or two
srebhan: Thanks a bunch!
essobedo: Hi @b5,  @srebhan asked me to help you on this, please check my contribution to it hoping it is useful https://github.com/qri-io/starlark-go/pull/1 
google-cla[bot]: We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for all the commit author(s) or Co-authors.  If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA ([login here](https://cla.developers.google.com/) to double check)?  If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and then comment `@googlebot I fixed it.`. If the bot doesn't comment, it means it doesn't think anything has changed.

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fstarlark-go%2Fpull%2F327) for more info**.

<!-- need_author_cla -->
essobedo: @googlebot I fixed it.
google-cla[bot]: All (the pull request submitter and all commit authors) CLAs are signed, **but** one or more commits were authored or co-authored by someone other than the pull request submitter.

We need to confirm that all authors are ok with their commits being contributed to this project.  Please have them confirm that by leaving a comment that contains only `@googlebot I consent.` in this pull request.

*Note to project maintainer:* There may be cases where the author cannot leave a comment, or the comment is not properly detected as consent.  In those cases, you can manually confirm consent of the commit author(s), and set the `cla` label to `yes` (if enabled on your project).

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fstarlark-go%2Fpull%2F327) for more info**.

<!-- need_author_consent -->
essobedo: @googlebot I consent.
essobedo: @alandonovan I think I went through all your remarks but the one about the `doc.go`, could you please clarify a little bit more what you have in mind? If you could check the rest also, it would be great. Thank you in advance 🙏 
essobedo: @alandonovan What do you suggest for `lib/time/doc.go`? Maybe you would rather prefer that we remove it?
b5: I'd vote to remove it. This PR shouldn't introduce a change to documentation conventions. It originated from a different codebase, and we can still maintain this form of documentation within that codebase
essobedo: @b5 I've just removed it
essobedo: FYI I renamed the package to `starlarktime` to remain consistent with other modules like the json module
adonovan: Sorry for radio silence: I'm on vacation at the moment but will attend to
this PR fully on Monday.

On Sat, 20 Feb 2021 at 09:32, Nicolas Filotto <notifications@github.com>
wrote:

> FYI I renamed the package to starlarktime to remain consistent with other
> modules like the json module
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/google/starlark-go/pull/327#issuecomment-782690428>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ABLFMP37RNWFAHJWNEUPBBDS77BWPANCNFSM4UNXYUDQ>
> .
>

essobedo: @alandonovan Remarks applied, please check again 
ssoroka: Any update on this? :)
essobedo: @b5 I cannot resolve conversations, could you please check if it is possible to give me the necessary rights?
essobedo: @adonovan @alandonovan We really need this feature for https://github.com/influxdata/telegraf, so it is important for us to finish it and have it in Startlark. Can you still do the review or shall I ask someone else? and if so, who is the new project lead now please? (cc @ssoroka @srebhan)
essobedo: @adonovan Thx for the feedbacks, it is hard to follow it properly since I have no way to resolve the discussions but I think that I addressed your feedbacks, could you please check it again? 
b5: quick think you to everyone who got this off the ground! @srebhan for motivating this in the first place, and @essobedo for putting in a huge amount of work to implement @adonovan's careful review 😄
srebhan: @b5 I also want to thank you for your continuous effort in bringing this forward. I know it's not easy to find the time. Special thanks to @essobedo for investing so much and pushing this over the finishing line!
essobedo: @adonovan Thanks for the impressive review, can you still somehow merge ? or do you at least know who can merge?

Patch:
diff --git a/cmd/starlark/starlark.go b/cmd/starlark/starlark.go
index 3825f00..fb53555 100644
--- a/cmd/starlark/starlark.go
+++ b/cmd/starlark/starlark.go
@@ -16,6 +16,7 @@ import (
 	"strings"
 
 	"go.starlark.net/internal/compile"
+	"go.starlark.net/lib/time"
 	"go.starlark.net/repl"
 	"go.starlark.net/resolve"
 	"go.starlark.net/starlark"
@@ -91,6 +92,7 @@ func doMain() int {
 	// Ideally this statement would update the predeclared environment.
 	// TODO(adonovan): plumb predeclared env through to the REPL.
 	starlark.Universe["json"] = starlarkjson.Module
+	starlark.Universe["time"] = time.Module
 
 	switch {
 	case flag.NArg() == 1 || *execprog != "":
diff --git a/lib/time/time.go b/lib/time/time.go
new file mode 100644
index 0000000..89f7b76
--- /dev/null
+++ b/lib/time/time.go
@@ -0,0 +1,479 @@
+package time
+
+import (
+	"fmt"
+	"sort"
+	"time"
+
+	"go.starlark.net/starlark"
+	"go.starlark.net/starlarkstruct"
+	"go.starlark.net/syntax"
+)
+
+// Module time is a Starlark module of time-related functions.
+var Module = &starlarkstruct.Module{
+	Name: "time",
+	Members: starlark.StringDict{
+		"parse_duration":    starlark.NewBuiltin("parse_duration", parseDuration),
+		"is_valid_timezone": starlark.NewBuiltin("is_valid_timezone", isValidTimezone),
+		"now":               starlark.NewBuiltin("now", now),
+		"time":              starlark.NewBuiltin("time", newTime),
+		"parse_time":        starlark.NewBuiltin("parse_time", parseTime),
+		"from_timestamp":    starlark.NewBuiltin("from_timestamp", fromTimestamp),
+
+		"nanosecond":  Duration(time.Nanosecond),
+		"microsecond": Duration(time.Microsecond),
+		"millisecond": Duration(time.Millisecond),
+		"second":      Duration(time.Second),
+		"minute":      Duration(time.Minute),
+		"hour":        Duration(time.Hour),
+	},
+}
+
+// NowFunc is a function that generates the current time. Intentionally exported
+// so that it can be overridden, for example by applications that require their
+// Starlark scripts to be fully deterministic.
+var NowFunc = time.Now
+
+// Parses the given duration string.
+// A duration string is a possibly signed sequence of
+// decimal numbers, each with optional fraction and a unit suffix,
+// such as "300ms", "-1.5h" or "2h45m".
+// Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
+func parseDuration(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var d Duration
+	err := starlark.UnpackPositionalArgs("parse_duration", args, kwargs, 1, &d)
+	return d, err
+}
+
+// Indicates whether the given name of location is valid or not.
+// Returns true if it is valid, false otherwise.
+func isValidTimezone(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var s string
+	if err := starlark.UnpackPositionalArgs("is_valid_timezone", args, kwargs, 1, &s); err != nil {
+		return nil, err
+	}
+	_, err := time.LoadLocation(s)
+	return starlark.Bool(err == nil), nil
+}
+
+// The expected arguments are a time string (mandatory), a time format (optional, set to RFC3339 by default)
+// and a name of location (optional set to UTC by default).
+// Parses the given time string using a specific time format and location.
+func parseTime(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var (
+		x        string
+		location = "UTC"
+		format   = time.RFC3339
+	)
+	if err := starlark.UnpackArgs("parse_time", args, kwargs, "x", &x, "format?", &format, "location?", &location); err != nil {
+		return nil, err
+	}
+
+	if location == "UTC" {
+		t, err := time.Parse(format, x)
+		if err != nil {
+			return nil, err
+		}
+		return Time(t), nil
+	}
+
+	loc, err := time.LoadLocation(location)
+	if err != nil {
+		return nil, err
+	}
+	t, err := time.ParseInLocation(format, x, loc)
+	if err != nil {
+		return nil, err
+	}
+	return Time(t), nil
+}
+
+// Parses the given timestamp string corresponding to
+// the total amount of seconds since January 1, 1970 UTC.
+func fromTimestamp(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var x int64
+	if err := starlark.UnpackPositionalArgs("from_timestamp", args, kwargs, 1, &x); err != nil {
+		return nil, err
+	}
+	return Time(time.Unix(x, 0)), nil
+}
+
+// Generates the current time current time.
+func now(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	return Time(NowFunc()), nil
+}
+
+// Duration is a Starlark representation of a duration.
+type Duration time.Duration
+
+// Assert at compile time that Duration implements Unpacker.
+var _ starlark.Unpacker = (*Duration)(nil)
+
+// Unpack is a custom argument unpacker
+func (d *Duration) Unpack(v starlark.Value) error {
+	switch x := v.(type) {
+	case Duration:
+		*d = x
+		return nil
+	case starlark.String:
+		dur, err := time.ParseDuration(string(x))
+		if err != nil {
+			return err
+		}
+
+		*d = Duration(dur)
+		return nil
+	}
+
+	return fmt.Errorf("got %s, want a duration, string, or int", v.Type())
+}
+
+// String implements the Stringer interface.
+func (d Duration) String() string { return time.Duration(d).String() }
+
+// Type returns a short string describing the value's type.
+func (d Duration) Type() string { return "time.duration" }
+
+// Freeze renders Duration immutable. required by starlark.Value interface
+// because duration is already immutable this is a no-op.
+func (d Duration) Freeze() {}
+
+// Hash returns a function of x such that Equals(x, y) => Hash(x) == Hash(y)
+// required by starlark.Value interface.
+func (d Duration) Hash() (uint32, error) {
+	return uint32(d) ^ uint32(int64(d)>>32), nil
+}
+
+// Truth reports whether the duration is non-zero.
+func (d Duration) Truth() starlark.Bool { return d != 0 }
+
+// Attr gets a value for a string attribute, implementing dot expression support
+// in starklark. required by starlark.HasAttrs interface.
+func (d Duration) Attr(name string) (starlark.Value, error) {
+	switch name {
+	case "hours":
+		return starlark.Float(time.Duration(d).Hours()), nil
+	case "minutes":
+		return starlark.Float(time.Duration(d).Minutes()), nil
+	case "seconds":
+		return starlark.Float(time.Duration(d).Seconds()), nil
+	case "milliseconds":
+		return starlark.MakeInt64(time.Duration(d).Milliseconds()), nil
+	case "microseconds":
+		return starlark.MakeInt64(time.Duration(d).Microseconds()), nil
+	case "nanoseconds":
+		return starlark.MakeInt64(time.Duration(d).Nanoseconds()), nil
+	}
+	return nil, fmt.Errorf("unrecognized %s attribute %q", d.Type(), name)
+}
+
+// AttrNames lists available dot expression strings. required by
+// starlark.HasAttrs interface.
+func (d Duration) AttrNames() []string {
+	return []string{
+		"hours",
+		"minutes",
+		"seconds",
+		"milliseconds",
+		"microseconds",
+		"nanoseconds",
+	}
+}
+
+// CompareSameType implements comparison of two Duration values. required by
+// starlark.Comparable interface.
+func (d Duration) CompareSameType(op syntax.Token, v starlark.Value, depth int) (bool, error) {
+	cmp := 0
+	if x, y := d, v.(Duration); x < y {
+		cmp = -1
+	} else if x > y {
+		cmp = 1
+	}
+	return threeway(op, cmp), nil
+}
+
+// Binary implements binary operators, which satisfies the starlark.HasBinary
+// interface. operators:
+//    duration + duration = duration
+//    duration + time = time
+//    duration - duration = duration
+//    duration / duration = float
+//    duration / int = duration
+//    duration / float = duration
+//    duration // duration = int
+//    duration * int = duration
+func (d Duration) Binary(op syntax.Token, y starlark.Value, side starlark.Side) (starlark.Value, error) {
+	x := time.Duration(d)
+
+	switch op {
+	case syntax.PLUS:
+		switch y := y.(type) {
+		case Duration:
+			return Duration(x + time.Duration(y)), nil
+		case Time:
+			return Time(time.Time(y).Add(x)), nil
+		}
+
+	case syntax.MINUS:
+		switch y := y.(type) {
+		case Duration:
+			return Duration(x - time.Duration(y)), nil
+		}
+
+	case syntax.SLASH:
+		switch y := y.(type) {
+		case Duration:
+			if y == 0 {
+				return nil, fmt.Errorf("%s division by zero", d.Type())
+			}
+			return starlark.Float(x.Nanoseconds()) / starlark.Float(time.Duration(y).Nanoseconds()), nil
+		case starlark.Int:
+			if side == starlark.Right {
+				return nil, fmt.Errorf("unsupported operation")
+			}
+			i, ok := y.Int64()
+			if !ok {
+				return nil, fmt.Errorf("int value out of range (want signed 64-bit value)")
+			}
+			if i == 0 {
+				return nil, fmt.Errorf("%s division by zero", d.Type())
+			}
+			return d / Duration(i), nil
+		case starlark.Float:
+			f := float64(y)
+			if f == 0 {
+				return nil, fmt.Errorf("%s division by zero", d.Type())
+			}
+			return Duration(float64(x.Nanoseconds()) / f), nil
+		}
+
+	case syntax.SLASHSLASH:
+		switch y := y.(type) {
+		case Duration:
+			if y == 0 {
+				return nil, fmt.Errorf("%s division by zero", d.Type())
+			}
+			return starlark.MakeInt64(x.Nanoseconds() / time.Duration(y).Nanoseconds()), nil
+		}
+
+	case syntax.STAR:
+		switch y := y.(type) {
+		case starlark.Int:
+			i, ok := y.Int64()
+			if !ok {
+				return nil, fmt.Errorf("int value out of range (want signed 64-bit value)")
+			}
+			return d * Duration(i), nil
+		}
+	}
+
+	return nil, nil
+}
+
+// Time is a Starlark representation of a moment in time.
+type Time time.Time
+
+func newTime(thread *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var (
+		year, month, day, hour, min, sec, nsec int
+		loc                                    string
+	)
+	if err := starlark.UnpackArgs("time", args, kwargs,
+		"year?", &year,
+		"month?", &month,
+		"day?", &day,
+		"hour?", &hour,
+		"minute?", &min,
+		"second?", &sec,
+		"nanosecond?", &nsec,
+		"location?", &loc,
+	); err != nil {
+		return nil, err
+	}
+	if len(args) > 0 {
+		return nil, fmt.Errorf("time: unexpected positional arguments")
+	}
+	location, err := time.LoadLocation(loc)
+	if err != nil {
+		return nil, err
+	}
+	return Time(time.Date(year, time.Month(month), day, hour, min, sec, nsec, location)), nil
+}
+
+// String returns the time formatted using the format string
+//	"2006-01-02 15:04:05.999999999 -0700 MST".
+func (t Time) String() string { return time.Time(t).String() }
+
+// Type returns "time.time".
+func (t Time) Type() string { return "time.time" }
+
+// Freeze renders time immutable. required by starlark.Value interface
+// because Time is already immutable this is a no-op.
+func (t Time) Freeze() {}
+
+// Hash returns a function of x such that Equals(x, y) => Hash(x) == Hash(y)
+// required by starlark.Value interface.
+func (t Time) Hash() (uint32, error) {
+	return uint32(time.Time(t).UnixNano()) ^ uint32(int64(time.Time(t).UnixNano())>>32), nil
+}
+
+// Truth returns the truth value of an object required by starlark.Value
+// interface.
+func (t Time) Truth() starlark.Bool { return starlark.Bool(time.Time(t).IsZero()) }
+
+// Attr gets a value for a string attribute, implementing dot expression support
+// in starklark. required by starlark.HasAttrs interface.
+func (t Time) Attr(name string) (starlark.Value, error) {
+	switch name {
+	case "year":
+		return starlark.MakeInt(time.Time(t).Year()), nil
+	case "month":
+		return starlark.MakeInt(int(time.Time(t).Month())), nil
+	case "day":
+		return starlark.MakeInt(time.Time(t).Day()), nil
+	case "hour":
+		return starlark.MakeInt(time.Time(t).Hour()), nil
+	case "minute":
+		return starlark.MakeInt(time.Time(t).Minute()), nil
+	case "second":
+		return starlark.MakeInt(time.Time(t).Second()), nil
+	case "nanosecond":
+		return starlark.MakeInt(time.Time(t).Nanosecond()), nil
+	case "unix":
+		return starlark.MakeInt64(time.Time(t).Unix()), nil
+	case "unix_nano":
+		return starlark.MakeInt64(time.Time(t).UnixNano()), nil
+	}
+	return builtinAttr(t, name, timeMethods)
+}
+
+// AttrNames lists available dot expression strings for time. required by
+// starlark.HasAttrs interface.
+func (t Time) AttrNames() []string {
+	return append(builtinAttrNames(timeMethods),
+		"year",
+		"month",
+		"day",
+		"hour",
+		"minute",
+		"second",
+		"nanosecond",
+		"unix",
+		"unix_nano",
+	)
+}
+
+// CompareSameType implements comparison of two Time values. required by
+// starlark.Comparable interface.
+func (t Time) CompareSameType(op syntax.Token, yV starlark.Value, depth int) (bool, error) {
+	x := time.Time(t)
+	y := time.Time(yV.(Time))
+	cmp := 0
+	if x.Before(y) {
+		cmp = -1
+	} else if x.After(y) {
+		cmp = 1
+	}
+	return threeway(op, cmp), nil
+}
+
+// Binary implements binary operators, which satisfies the starlark.HasBinary
+// interface
+//    time + duration = time
+//    time - duration = time
+//    time - time = duration
+func (t Time) Binary(op syntax.Token, y starlark.Value, side starlark.Side) (starlark.Value, error) {
+	x := time.Time(t)
+
+	switch op {
+	case syntax.PLUS:
+		switch y := y.(type) {
+		case Duration:
+			return Time(x.Add(time.Duration(y))), nil
+		}
+	case syntax.MINUS:
+		switch y := y.(type) {
+		case Duration:
+			return Time(x.Add(time.Duration(-y))), nil
+		case Time:
+			// time - time = duration
+			return Duration(x.Sub(time.Time(y))), nil
+		}
+	}
+
+	return nil, nil
+}
+
+var timeMethods = map[string]builtinMethod{
+	"in_location": timeIn,
+	"format":      timeFormat,
+}
+
+func timeFormat(fnname string, recV starlark.Value, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var x string
+	if err := starlark.UnpackPositionalArgs("format", args, kwargs, 1, &x); err != nil {
+		return nil, err
+	}
+
+	recv := time.Time(recV.(Time))
+	return starlark.String(recv.Format(x)), nil
+}
+
+func timeIn(fnname string, recV starlark.Value, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var x string
+	if err := starlark.UnpackPositionalArgs("in_location", args, kwargs, 1, &x); err != nil {
+		return nil, err
+	}
+	loc, err := time.LoadLocation(x)
+	if err != nil {
+		return nil, err
+	}
+
+	recv := time.Time(recV.(Time))
+	return Time(recv.In(loc)), nil
+}
+
+type builtinMethod func(fnname string, recv starlark.Value, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error)
+
+func builtinAttr(recv starlark.Value, name string, methods map[string]builtinMethod) (starlark.Value, error) {
+	method := methods[name]
+	if method == nil {
+		return nil, nil // no such method
+	}
+
+	// Allocate a closure over 'method'.
+	impl := func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+		return method(b.Name(), b.Receiver(), args, kwargs)
+	}
+	return starlark.NewBuiltin(name, impl).BindReceiver(recv), nil
+}
+
+func builtinAttrNames(methods map[string]builtinMethod) []string {
+	names := make([]string, 0, len(methods))
+	for name := range methods {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+	return names
+}
+
+// Threeway interprets a three-way comparison value cmp (-1, 0, +1)
+// as a boolean comparison (e.g. x < y).
+func threeway(op syntax.Token, cmp int) bool {
+	switch op {
+	case syntax.EQL:
+		return cmp == 0
+	case syntax.NEQ:
+		return cmp != 0
+	case syntax.LE:
+		return cmp <= 0
+	case syntax.LT:
+		return cmp < 0
+	case syntax.GE:
+		return cmp >= 0
+	case syntax.GT:
+		return cmp > 0
+	}
+	panic(op)
+}


Test patch:
diff --git a/starlark/eval_test.go b/starlark/eval_test.go
index 25d1b72..7fbba43 100644
--- a/starlark/eval_test.go
+++ b/starlark/eval_test.go
@@ -16,6 +16,7 @@ import (
 	"testing"
 
 	"go.starlark.net/internal/chunkedfile"
+	"go.starlark.net/lib/time"
 	"go.starlark.net/resolve"
 	"go.starlark.net/starlark"
 	"go.starlark.net/starlarkjson"
@@ -126,6 +127,7 @@ func TestExecFile(t *testing.T) {
 		"testdata/misc.star",
 		"testdata/set.star",
 		"testdata/string.star",
+		"testdata/time.star",
 		"testdata/tuple.star",
 		"testdata/recursion.star",
 		"testdata/module.star",
@@ -193,6 +195,9 @@ func load(thread *starlark.Thread, module string) (starlark.StringDict, error) {
 	if module == "json.star" {
 		return starlark.StringDict{"json": starlarkjson.Module}, nil
 	}
+	if module == "time.star" {
+		return starlark.StringDict{"time": time.Module}, nil
+	}
 
 	// TODO(adonovan): test load() using this execution path.
 	filename := filepath.Join(filepath.Dir(thread.CallFrame(0).Pos.Filename()), module)
diff --git a/starlark/testdata/time.star b/starlark/testdata/time.star
new file mode 100644
index 0000000..36975cc
--- /dev/null
+++ b/starlark/testdata/time.star
@@ -0,0 +1,160 @@
+# Tests of time module.
+
+load('assert.star', 'assert')
+load('time.star', 'time')
+
+assert.eq(time.parse_time("2020-06-26T17:38:36Z"), time.from_timestamp(1593193116))
+
+assert.eq(time.parse_time("1970-01-01T00:00:00Z").unix, 0)
+assert.eq(time.parse_time("1970-01-01T00:00:00Z").unix_nano, 0)
+
+t = time.parse_time("2000-01-02T03:04:05Z")
+assert.eq(t.year, 2000)
+assert.eq(t.in_location("US/Eastern"), time.parse_time("2000-01-01T22:04:05-05:00"))
+assert.eq(t.in_location("US/Eastern").format("3 04 PM"), "10 04 PM")
+
+assert.eq(t - t, time.parse_duration("0s"))
+
+d1s = time.parse_duration("1s")
+assert.eq(d1s - d1s, time.parse_duration("0"))
+assert.eq(d1s + d1s, time.parse_duration("2s"))
+assert.eq(d1s * 5, time.parse_duration("5s"))
+assert.eq(time.parse_duration("0s") + time.parse_duration("3m35s"), time.parse_duration("3m35s"))
+
+d10h = time.parse_duration("10h")
+# duration attributes
+assert.eq(10.0, d10h.hours)
+assert.eq(10*60.0, d10h.minutes)
+assert.eq(10*60*60.0, d10h.seconds)
+assert.eq(10*60*60*1000, d10h.milliseconds)
+assert.eq(10*60*60*1000000, d10h.microseconds)
+assert.eq(10*60*60*1000000000, d10h.nanoseconds)
+
+# duration type
+assert.eq("time.duration", type(d10h))
+# duration str
+assert.eq("10h0m0s", str(d10h))
+# duration hash
+durations = {
+    d10h: "10h",
+    d1s: "10s",
+}
+assert.eq("10h", durations[d10h])
+assert.eq("10s", durations[d1s])
+
+# duration == duration
+# duration != duration
+assert.eq(time.parse_duration("1h"), time.parse_duration("1h"))
+assert.ne(time.parse_duration("1h"), time.parse_duration("1m"))
+# duration < duration
+assert.lt(time.parse_duration("1m"), time.parse_duration("1h"))
+assert.true(not time.parse_duration("1h") < time.parse_duration("1h"))
+assert.true(not time.parse_duration("1h") < time.parse_duration("1m"))
+# duration <= duration
+assert.true(time.parse_duration("1m") <= time.parse_duration("1h"))
+assert.true(time.parse_duration("1h") <= time.parse_duration("1h"))
+assert.true(not time.parse_duration("1h") <= time.parse_duration("1m"))
+# duration > duration
+assert.true(not time.parse_duration("1m") > time.parse_duration("1h"))
+assert.true(not time.parse_duration("1h") > time.parse_duration("1h"))
+assert.true(time.parse_duration("1h") > time.parse_duration("1m"))
+# duration >= duration
+assert.true(not time.parse_duration("1m") >= time.parse_duration("1h"))
+assert.true(time.parse_duration("1h") >= time.parse_duration("1h"))
+assert.true(time.parse_duration("1h") >= time.parse_duration("1m"))
+
+refTime = time.parse_time("2011-04-22T13:33:48Z")
+tenHoursAfterRefTime = time.parse_time("2011-04-22T23:33:48Z")
+
+# duration + duration = duration
+assert.eq(d10h + d1s, time.parse_duration("10h01s"))
+# duration + time = time
+assert.eq(d10h + refTime, tenHoursAfterRefTime)
+# duration - duration = duration
+assert.eq(d10h - d1s, time.parse_duration("9h59m59s"))
+# duration / duration = float
+assert.eq(d10h / time.parse_duration("16m"), 37.5)
+assert.fails(lambda: d10h / time.parse_duration("0"), "division by zero")
+# duration / int = duration
+assert.eq(d10h / 20, time.parse_duration("30m"))
+assert.fails(lambda: d10h / 0, "division by zero")
+# int / duration = error
+assert.fails(lambda: 20 / d10h, "unsupported operation")
+# duration / float = duration
+assert.eq(d10h / 37.5, time.parse_duration("16m"))
+assert.fails(lambda: d10h / 0.0, "division by zero")
+# duration // duration = int
+assert.eq(d10h // time.parse_duration("16m"), 37)
+assert.fails(lambda: d10h // time.parse_duration("0"), "division by zero")
+# duration * int = duration
+assert.eq(d1s * 1000, time.parse_duration("16m40s"))
+# int * duration  = duration
+assert.eq(1000 * d1s, time.parse_duration("16m40s"))
+
+# is_valid_timezone(location)
+assert.true(time.is_valid_timezone("UTC"))
+assert.true(time.is_valid_timezone("US/Eastern"))
+assert.true(not time.is_valid_timezone("UKN"))
+
+# time(year=..., month=..., day=..., hour=..., minute=..., second=..., nanosecond=..., location=...)
+assert.fails(lambda: time.time(2009, 6, 12, 12, 6, 10, 99, "US/Eastern"), "unexpected positional argument")
+t1 = time.time(year=2009, month=6, day=12, hour=12, minute=6, second=10, nanosecond=99, location="US/Eastern")
+assert.eq(t1, time.parse_time("2009-06-12T12:06:10.000000099", format="2006-01-02T15:04:05.999999999", location="US/Eastern"))
+assert.eq(time.time(year=2012, month=12, day=31), time.parse_time("2012-12-31T00:00:00Z"))
+assert.eq(time.time(year=2009, month=6, day=12, hour=12, minute=6, second=10, nanosecond=99, location="UTC"), time.time(year=2009, month=6, day=12, hour=12, minute=6, second=10, nanosecond=99))
+
+# time attributes
+assert.eq(2009, t1.year)
+assert.eq(6, t1.month)
+assert.eq(12, t1.day)
+assert.eq(12, t1.hour)
+assert.eq(6, t1.minute)
+assert.eq(10, t1.second)
+assert.eq(99, t1.nanosecond)
+assert.eq(1244822770, t1.unix)
+assert.eq(1244822770000000099, t1.unix_nano)
+
+# time type
+assert.eq("time.time", type(refTime))
+# duration str
+assert.eq("2011-04-22 13:33:48 +0000 UTC", str(refTime))
+# duration hash
+times = {
+    refTime: "refTime",
+    t1: "t1",
+}
+assert.eq("refTime", times[refTime])
+assert.eq("t1", times[t1])
+
+oneSecondAfterRefTime = time.parse_time("2011-04-22T13:33:49Z")
+oneYearAfterRefTime = time.parse_time("2012-04-22T13:33:48Z")
+oneYearBeforeRefTime = time.parse_time("2010-04-22T13:33:48Z")
+twoYearsBeforeRefTime = time.parse_time("2009-04-22T13:33:48Z")
+tenHoursBeforeRefTime = time.parse_time("2011-04-22T03:33:48Z")
+
+# time == time
+# time != time
+assert.eq(refTime, refTime)
+assert.ne(refTime, oneSecondAfterRefTime)
+# time < time
+assert.lt(oneYearBeforeRefTime, refTime)
+assert.true(not oneYearBeforeRefTime < oneYearBeforeRefTime)
+assert.true(not oneYearBeforeRefTime < twoYearsBeforeRefTime)
+# time <= time
+assert.true(oneYearBeforeRefTime <= refTime)
+assert.true(oneYearBeforeRefTime <= oneYearBeforeRefTime)
+assert.true(not oneYearBeforeRefTime <= twoYearsBeforeRefTime)
+# time > time
+assert.true(oneYearAfterRefTime > refTime)
+assert.true(not refTime > refTime)
+assert.true(not oneYearBeforeRefTime > refTime)
+# time >= time
+assert.true(oneYearAfterRefTime >= refTime)
+assert.true(refTime >= refTime)
+assert.true(not oneYearBeforeRefTime >= refTime)
+# time + duration = time
+assert.eq(refTime + d10h, tenHoursAfterRefTime)
+# time - duration = time
+assert.eq(refTime - d10h, tenHoursBeforeRefTime)
+# time - time = duration
+assert.eq(refTime - tenHoursBeforeRefTime, d10h)