A Date with CPAN, Part 7: In the Zone
[This is a post in my latest long-ass series. You may want to begin at the beginning. I do not promise that the next post in the series will be next week. Just that I will eventually finish it, someday. Unless I get hit by a bus.
IMPORTANT NOTE! When I provide you links to code on GitHub, I’m giving you links to particular commits. This allows me to show you the code as it was at the time the blog post was written and insures that the code references will make sense in the context of this post. Just be aware that the latest version of the code may be very different.]
Last time I said that I had three things left to do for Date::Easy before it was ready for CPAN:
- Add a Time::ParseDate fallback to the ::Datetime class.
- Figure out how to handle the UTC version of datetimes.
- (Hopefully) fill out the POD.
This time, I’m reminded of the wise words of the great sage Meatloaf: two outta three ain’t bad.
Now, to be fair, I also found more stuff that needed doing, and the one thing in the list that I didn’t get around to was the POD, which I’ve tentatively decided won’t hold up my release plan. As a temporary solution (only!), I’ll just slap in a reference to this blog series and release it anyway. The POD will be my top priority after that—because a module named ::Easy without good docs is an oxymoron—but I don’t want to delay any more. Release early, release often, as they say.1
First, let’s look at the two other things I worked on that aren’t in the list above.
One was that I happened to notice a place in my code where I did something like this:
die "Illegal date: $date" unless $time;
That’s a bug. Do you see it? The trick here is, as it often is in Perl, distinguishing between “false” and “undefined.” Undefined is one of the several possible falses, but obviously not the only one. Zero, for instance, is false, as of course it should be.2 But zero is also a valid number of epoch seconds: a datetime of zero is just the exact second of midnight on January 1st, 1970, UTC. Which is not an illegal date at all. So, when dealing with date code (really, all the time in Perl, but especially with date code), one has to be careful about distinguishing false and undefined.
A very brief digression on TDD: When I find a bug like this, I don’t just fix it. According to TDD principles, first I write a failing unit test that identifies the bug, then I fix it. Often this seems like slavish adherence to a philosophy and not really useful in any way, other than being able to pat myself on the back and say what a good TDD boy am I. But, every once in a while, you’re reminded of why you do this, because your new unit test(s) not only find the bug you knew was there ... but also another bug you didn’t. So it was this time. Score one for TDD.
The second thing I did was to add more unit tests for error handling, which in turn led to more error handling. I also turned all the
croaks for clearer error reporting.
Back to the points I was actually supposed to be working on, adding the Time::ParseDate fallback to ::Datetime was almost boringly simple. It was mostly just a matter of copying my unit tests and tweaking slightly, and the ::Datetime version of
_parsedate was just three lines (compared to ::Date’s version, which is about 4 times that size). The only even remotely tricky part was that I uncovered something I’d apparently missed when reformatting my stolen unit tests.3
The other point—having a way to specify a UTC time rather than a local time—was a bit more complex, at least from a design perspective. I decided to go with something along these lines:
my $localtime = datetime("next Monday"); # yes, that really works
my $gmtime = datetime(GMT => "next Monday");
do_something() if $localtime == $gmtime; # most likely true
do_something() if $localtime eq $gmtime; # most likely false
A couple of notes here. First, you can also use
UTC instead of
GMT: while various articles online will explain the distinction between GMT and UTC—which some will say is a distinction without a difference—for Date::Easy’s purposes, they are treated as equivalent in all cases. So use whichever you find most natural. Secondly, what’s up with the “most likely"s? Well, of course the string representations of the two objects would be the same if your local timezone happens to be GMT. Why would the numerical representations (which are just the respective epoch seconds values) be different? Well, when you construct a datetime without specifying a time, you get the current time. So if you run that code at exactly noon on a Wednesday, the resulting datetime is for noon on the following Monday. (If that bugs you, you should probably be using dates instead of datetimes.4) Thus, there’s always a chance (a very small chance, granted, but still a chance) that the system clock rolls over to a new second in between constructing
$localtime and constructing
$gmtime. In that (rare) case,
$localtime + 1 == $gmtime instead.
Another thing to note is that this works with more than just
datetime. For instance, you can also do this:
my $gmtime = Date::Easy::Datetime->new(UTC => 2007, 3, 15, 8, 32, 23);
to get an object representing March 15th, 2007, 08:32:23 UTC. Although hopefully you won’t need to do that very often, as it’s not particularly intuitive—not very easy. But the capability is there, should you need it.
Of course, you might get tired of specifying
UTC constantly if you just wanted all your dates to be UTC. So I decided to make it easy to change that default. Now, you might think that it shouldn’t be using local time as a default in the first place, and that’s fair. The reasons are to avoid potential surprises such as this:
[absalom:~/proj/date-easy] date +%H
[absalom:~/proj/date-easy] perl -Ilib -MDate::Easy -le 'print datetime(UTC => "+1 hour")->strftime("%H")'
So it makes sense to me that defaulting to local time produces the most intuitive (the easiest) results. But others will disagree, and that’s okay.
My solution is to make it easy to change:
use Date::Easy 'UTC'; # or 'GMT'
my $gmtime = datetime("3/15/2007 08:32:23");
print $gmtime->strftime("%Z"); # prints "UTC" (but see below)
do_something() if $gmtime->is_gmt; # true
do_something() if $gmtime->is_utc; # GMT and UTC always equivalent to Date::Easy
do_something() if $gmtime->is_local; # false
Since this is stored in a package variable, you can also do things like this:
use Date::Easy::Datetime qw< UTC :all >; # can pass the zonespec here too
my ($gmtime1, $gmtime2, $localtime);
$gmtime1 = datetime("midnight");
local $Date::Easy::Datetime::DEFAULT_ZONE = 'local';
$localtime = datetime("midnight");
$gmtime2 = datetime("midnight");
do_something() if $gmtime1->is_gmt; # true
do_something() if $gmtime2->is_gmt; # true
do_something() if $localtime->is_gmt; # false
Observant readers will note that while this is pretty easy, it involves a global variable, which is bad in other ways (for instance, it’s not threadsafe). This was once again a carefully considered trade-off. I think if your program is complex enough that you might have multiple threads stepping on each other because they’re both localizing your zone specifier in different ways, you’ve probably outgrown Date::Easy anyway. There are ways around the global, of course, but I couldn’t think of any that would maintain the simplicity that we have here, and that of course is our primary goal. But I’m always open to suggestions.
There was only one other interesting issue that popped up. I had decided to refactor my unit tests, which allowed me to clean up my test code quite a bit, and also fix a couple of niggling issues (consolidating the time comparison formats, better handling of millisecond comparisons, etc). While doing this, I ran into one interesting problem, which turned out to be a bug in Time::Piece. It’s best illustrated by this simple piece of code:
[absalom:~/proj/date-easy] perl -MTime::Piece -le 'print gmtime->strftime("%z")'
Of course, that’s the offset for my local timezone, not GMT (which would be zero). After doing a bit of research, I found that I wasn’t the first person to find this bug, and it’s even been fixed already. Unfortunately, the fix only appears in version 1.30 of Time::Piece, which only shows up in Perl 5.23. So I have two choices: either I can just jack up my minimum required version of Time::Piece to 1.30, or I can wrap the
strftime method and employ the same fix that the newer version of Time::Piece uses. I’ve tentatively gone with the much simpler solution of requiring the Time::Piece upgrade. The only real downside here is that it will require an upgrade of Time::Piece for nearly every Perl version currently in the wild. But that’s not much of a downside: we have non-core dependencies, and, if you couldn’t install an upgrade because you couldn’t install anything, you wouldn’t have been able to install Date::Easy either. So I think this is fine. Still, I may reconsider this decision, especially if anyone can point out a reason why I should. Doing it the other way is annoying, and a bit redundant, but certainly not difficult.
I hope that, by the time you read this post, Date::Easy will be available on CPAN for your enjoyment.
The full code for Date::Easy so far is here. Of special note:
- the export code for Date::Easy got a bit more complex, as it has to pass on any zone specifier to Date::Easy::Datetime (but not to Exporter)
- method definitions for is_local() and friends (this just involved exposing a field that Time::Piece was already keeping track of for us)
- the compare_times() test function which allowed me to simplify a lot of my unit tests
- note especially this line, which is how you make a unit test function report the line number of the calling test (sort of like Carp for unit tests)
- my is_true and is_false test functions, which help test my boolean methods5
- I decided to remove the prototype on date() as I mentioned I might6
1 Honestly, I’ve always had difficulty with this maxim. As you might have guessed from my writings, I’m a bit of a perfectionist, and I hate to release things to the world in rough form. So this is a bit of an act of courage for me, as silly as that may seem to most of you. But I think it’s the right thing to do.
3 Related to
parsedate’s mildly annoying habit of returning a two-element list when called in list context. To verify this, the stolen unit tests sometimes have an arrayref as the “expected” value. Since I couldn’t care less about that second element, I just threw it away.
4 That’s why we have two separate classes, remember?
5 I really should submit these to someone for wider inclusion in the standard Perl test modules somewhere. They’re simple, but very useful in my exeperience.