Skip to content

Commit

Permalink
Autoupgrade of old DateTimes
Browse files Browse the repository at this point in the history
Some DateTimes created early (precompilation, etc) may not get created with the wrapped new method.  This update catches those when appropriate and upgrades them.
  • Loading branch information
alabamenhu committed Aug 10, 2020
1 parent cbee967 commit 5ed8a83
Show file tree
Hide file tree
Showing 8 changed files with 227 additions and 39 deletions.
2 changes: 1 addition & 1 deletion META6.json
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@
"perl": "6.*",
"name": "DateTime::Timezones",
"auth": "github:alabamenhu",
"version": "0.2.1",
"version": "0.3",
"description": "A module enabling experimental timezone support to DateTime",
"test-depends": [
"Test"
Expand Down
31 changes: 16 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# DateTime::Timezones

An extremely early and experimental module to extend the built in `DateTime` with timezone support.
An module to extend the built in `DateTime` with timezone support.
To use, simply include it at any point in your code:

```raku
use DateTime::Timezones;

my $dt = DateTime.new: now;
```

This extends `DateTime` to include the following three new attributes whose names *are subject to change*.
Expand All @@ -15,7 +17,7 @@ This extends `DateTime` to include the following three new attributes whose name
They do not tend to align with popular usage. In the United States, for instance, what is commonly called Eastern Time is listed as America/New_York).
* **`.tz-abbr`** *(Str)*
An (mostly) unique abbreviation for the timezone.
It normally is more representative of popular usage (so *EST* for Eastern Standard Time in America/New_York).
It normally is more representative of popular usage (so *EST* for Eastern Standard Time in America/New_York) and normally differs based on daylight savings time.
* **`.is-dst`** *(Bool)*
This value is `True` if the timezone is in what is commonly referred to as either Daylight Saving Time (hence the attribute name) or Summer Time where the timezone is shifted by (normally) one hour.
The value is `False` during Standard time which in many timezones is the only possible value.
Expand All @@ -25,25 +27,21 @@ For the most part, once you enable it, you won't need to do anything different a
There are, nonetheless, a few things to note:

* The default time zone is either **Etc/GMT** *or*, if you have `Intl::UserTimezone`, the one indicated by `user-timezone`.
* The attribute `timezone` has been modified slightly.
You may pass either an integer *or* an Olson ID.
If you pass an integer, it will be taken into account, but the resultant time will be zoned to GMT.
When accessing `timezone`, you will get the offset with the Olson ID mixed in, so it should Just Work™.
* The attribute `timezone` has been modified slightly to be allomorphic.
For creation, you may pass either an `Int` offset *or* a `Str` Olson ID.
Integer offsets are taken into account but the resultant time will be zoned to GMT (eventually whole-hour offsets will be be given an appropriate `Etc/` zone).
When accessing `.timezone`, you get an `IntStr` comprising the offset and the Olson ID, so it should Just Work™.
If you absolutely must have a strict `Int` value, use `.offset`, and for a strict `Str` value, use `.olson-id`
* **(NYI)** The formatter has been changed to indicate the timezone.
This makes it incompatible with RFC 3339.
The `use` option 'rfc3339' will restore the original formatter.
* Because of how precompilation works, it may be possible for some `DateTime` objects to have been created without having the `TimezoneAware` role mixed in.
Guards will *eventually* be added, but in the meantime, it's possible for some `DateTime` objects to not support timezones.
* I have absolutely no idea (read: have not tested) how `.later()` and `.earlier()` function.
* Using `.later()` and `.earlier()` methods are currently untested.
You may get unexpected results if you use them and cross a timezone transition.

### Leapseconds

Leapseconds are annoying for timekeeping.
POSIX explicitly ignores them.
Future ones are unpredictable because weird physics.
I do not have the expertise to ensure that leapseconds are handled correctly, but welcome any code review and/or pull requests to remedy this.
Leapseconds are annoying for timekeeping and POSIX explicitly ignores them since future ones are unpredictable because weird physics.
I do not have the expertise to ensure that leapseconds are handled correctly, but welcome any code review and/or pull requests to remedy this (particularly test cases).

## How does it work?

Expand All @@ -52,10 +50,13 @@ While the module initially planned on `augment`ing `DateTime`, it turns out that
Instead, `DateTime.new` is wrapped with a new method that returns the same (or functionally the same) `DateTime` you would have expected and mixes in the parameterized `TimezoneAware` role.
It has a few tricks to make sure it doesn't apply the role multiple times.

The data files come from the [IANA](https://www.iana.org/time-zones), although at the moment we don't do our own compiling of them to binary form.
Soon an easy updater script will be written for future maintainers.
The data files come from the [IANA](https://www.iana.org/time-zones), and are compiled using their zone information compiler (ZIC).

## Version history
- **0.3**
- Support for 'upgrading' timezone-unaware `DateTime` objects that may have been precompiled before this module's `use` statement.
- Additional test files.
- Added an example: see `world-clock.raku` and pass in your favorite timezones
- **0.2.1**
- Fixed creation from Instants by adding `.floor`
- **0.2**
Expand Down
68 changes: 67 additions & 1 deletion docs/blog.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ Before returning, we store the variable like so:
And violà, it looks totally normally, except it has those extra methods.

Now we tackle the second method, which is from being given discrete time units.
There is a `gmt-from-local` routine that takes the aforemention `tm` struct along with a timezone and tries to reconcile the two to get a POSIX time (if you ask me for 2:30 on a day we spring forward… we'll have problems).
There is a `gmt-from-local` routine that takes the aforementioned `tm` struct along with a timezone and tries to reconcile the two to get a POSIX time (if you ask me for 2:30 on a day we spring forward… we'll have problems).
Once we have the POSIX time, then we can create things like before.
I'll spare you all the different ways that this type of creation can happen, but it's easy enough to imagine.

Expand All @@ -168,6 +168,72 @@ Things like `.day`, `.month`, etc, should all work the same, since the original

The important one is `to-timezone` where all we need to do, really, is wrap it and call `.new(self, :$timezone)`.

## Regenerating DateTime

Wrapping is actually a very pervasive thing: you cannot lexically scope it, and so if we wrap at INIT, its effects are seen globally from the get-go.
Except... there are two phasers that fire *before* INIT.
They are BEGIN and CHECK.
If someone were to create a `DateTime` in one of these blocks, it will still be a regular `DateTime` without our mixin.
Consider the following:

my $compile-time = BEGIN DateTime.new: now;

What should we do about this? If it gets used later, it won't have the attributes that users might depend on.
How can we help this out?

Firstly, if the user calls `.day`, there won't be a difference, so we can pass that through.
But if the user calls, say, `olson-id`, we're in trouble.
No such method. Or is there?

Raku objects have a special (psuedo) method called `FALLBACK` that is called when an unknown method is called.
I say *psuedo* method because while you can define it is `method FALLBACK`, it's not actually stored as such.
As a result, it's impossible to say `.^find_method('FALLBACK').wrap(…)`.
Nonetheless, the same HOW that gives us `^find_method` also gives us `^add_fallback`, although its syntax is a bit trickier.

For example, for the Olson ID method, we can do the following:

INIT DateTime.^add_fallback:
anon sub condition ($invocant, $method-name --> Bool ) { $method-name eq 'olson-id' }
anon sub calculator ($invocant, $method-name --> Callable) { method { … } };
Now, even if one of these old school `DateTime` objects manages to stick around, we can do something.
But what can we do? As it turns out, a lot... depending.

We could just try to run a fresh set of calculations.
If the same old-fashioned `DateTime` has us call the method on it regularly, though, then we're wasting a lot of CPU cycles.
Instead, we can actually replace the object! While the trait `is rw` is fairly well-known, much less well-known is that it can exist *on the invocant!*
The only catch is we need to have a scalar container for the invocant, which is done by giving it a sigil:

method ($self is rw: |c) {
$self = …
}

There is one small catch, though. If the `DateTime` is not in a container (for example, it's a constant), we're not only stuck, but the above method will error because `is rw` requires a writable container.
In that case, we'll need to fallback to recalculating each time. Small price to pay.
But how can we even know? Or make it work since the above errors with unwritable containers?
Simple answer: multi methods. Miraculously, if you have two identical methods, but for the trait `is rw`, then dispatch will prefer the `is rw` for writable containers, and the other for unwritables.

multi method foo ($self is rw: |c) {
self = … # upgrade for faster calls later
}
multi method foo ($self: |c) {
calculate-with($self) # slower style here
}

The catch is you can't pass a multi method. In fact, multi methods can *only* be properly declared and referenced inside of a class declaration.
The solution is to instead make a multi **sub** outside of `wrap`'s parentheses, and then refer to it with its sigiled self when wrapping:

proto sub foo (|) { * }
multi sub foo ($self is rw, |c) {
self = … # ^ notice the comma, subs don't have invocants,
} # but they're passed as the first argument
multi sub foo ($self, |c) {
calculate-with($self)
}
….wrap(&foo);

Wrapping, multiple dispatch, first-class functions, so much stuff going on but we avoid breaking precompilation and manage to not make a single use of `MONKEY-TYPING` :-)

## Bowties are cool

There are a lot of other little niceties that can be given for users.
Expand Down
11 changes: 10 additions & 1 deletion examples/world-clock.raku
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
use DateTime::Timezones;
sub MAIN (**@timezones) {
sub MAIN (**@timezones where * > 0) {
react whenever Supply.interval(1) {
my $time = DateTime.new: now;
say " {.hh-mm-ss} {.tz-abbr}\t{.olson-id}"
for @timezones.map({$time.in-timezone: $_});
print "\x001b[F" xx @timezones;
}
}

sub USAGE {
print q:to/END/
Usage: raku world-clock.raku Olson/ID Olson/ID …
Each Olson ID should be in the format of Region/City.
Updates with the time for the given zones each second.
END
}
100 changes: 81 additions & 19 deletions lib/DateTime/Timezones.pm6
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ role TimezoneAware[$olson = "Etc/GMT", $abbr = "GMT", $dst = False] {
method tz-abbr (--> Str) { $abbr }
method is-dst (--> Bool) { $dst }
}
subset NotTimezoneAware of DateTime where * !~~ TimezoneAware;

use DateTime::Timezones::Routines;

Expand Down Expand Up @@ -89,13 +90,14 @@ INIT DateTime.^find_method('new').wrap(
} elsif c ~~ :(DateTime $, *%) {
my \orig = c.list.head;
my \args = c.hash;
time-in.year = args<year> // orig.year;
time-in.month = args<month> // orig.month;
time-in.day = args<day> // orig.day;
time-in.hour = args<hour> // orig.hour;
time-in.minute = args<minute> // orig.minute;
time-in.second = args<second>.floor // orig.second.floor; # may be fractional
time-in.dst = args<daylight> // -1;

time-in.year = args<year> // orig.year;
time-in.month = args<month> // orig.month;
time-in.day = args<day> // orig.day;
time-in.hour = args<hour> // orig.hour;
time-in.minute = args<minute> // orig.minute;
time-in.second = args<second> ?? args<second>.floor !! orig.second.floor;
time-in.dst = args<daylight> // -1;
time-in.gmt-offset = args<timezone> ~~ Int ?? args<timezone> !! orig.offset;
} elsif c ~~ :(Int() $Y, Int() $M, Int() $D,
Int() $h, Int() $m, Numeric() $s) {
Expand Down Expand Up @@ -141,27 +143,39 @@ INIT DateTime.^find_method('new').wrap(

# Now we run it back through localtime, to ensure we have the right
# offsets and dst settings.
say "When checking, our time object is ", time;

my \time-out = localtime tz, time;
say "Time out is", time-out;

my $*USE-ORIGINAL-DATETIME-NEW = True;
my \tz-aware = callwith(self, time)
but TimezoneAware[tz-id,time-out.tz-abbr,time-out.dst];
but TimezoneAware[tz-id,time-out.tz-abbr,time-out.dst.Bool];
tz-aware.^set_name('DateTime');
return tz-aware

}
);

INIT DateTime.^find_method('timezone').wrap(
method (|c) {
with self.?olson-id {
callsame() but self.?olson-id
}else{
callsame()
}
# If we have a writable container and someone calls timezone, we
# should attempt to upgrade to being TimezoneAware. Otherwise,
# we have to convert on each call. Because we'll rely on multis,
# the wrapper has to be declared separately, but INIT scoping keeps
# us from polluting. Only problem is that if we error, our
# message will refer to the *sub* timezone, potentially confusing.
INIT {
proto sub timezone (|) { * }
multi sub timezone (TimezoneAware $self, |c) is default {
IntStr.new: $self.offset, $self.olson-id
}
);
multi sub timezone (NotTimezoneAware $self is rw, |c) {
$self = DateTime.new: $self;
IntStr.new: $self.offset, $self.olson-id
}
multi sub timezone (NotTimezoneAware $self, |c) {
DateTime.new($self).timezone
}

DateTime.^find_method('timezone').wrap: &timezone
}

INIT DateTime.^find_method('in-timezone').wrap(
method (|c) {
Expand Down Expand Up @@ -195,4 +209,52 @@ INIT DateTime.^find_method('posix').wrap(
die "No arguments allowed for .posix";
}
}
)
);

# The TimezoneAware methods require fallback support.
# Because they must be multi'd (depending on the writability of their container)
# we first define each of them as multi subs.
#
# To add as a fallback, the syntax is a bit trickier, wherein we create two
# blocks, one that checks if the method name matches us (always True), and the
# second that returns the actual method (sub, in our case) to be used.

INIT {
proto sub olson-id (|) { * }
multi sub olson-id (DateTime $self is rw, |c) {
$self = DateTime.new: $self;
$self.olson-id
}
multi sub olson-id (DateTime $self, |c) {
DateTime.new($self).olson-id
}

proto sub tz-abbr (|) { * }
multi sub tz-abbr (DateTime $self is rw, |c) {
$self = DateTime.new: $self;
$self.tz-abbr
}
multi sub tz-abbr (DateTime $self, |c) {
DateTime.new($self).tz-abbr
}

proto sub is-dst (|) { * }
multi sub is-dst (DateTime $self is rw, |c) {
$self = DateTime.new: $self;
$self.is-dst
}
multi sub is-dst (DateTime $self, |c) {
DateTime.new($self).is-dst
}


DateTime.^add_fallback:
anon sub condition ($object, $want) { $want eq 'olson-id' },
anon sub calculate ($object, $want) { &olson-id };
DateTime.^add_fallback:
anon sub condition ($object, $want) { $want eq 'tz-abbr' },
anon sub calculate ($object, $want) { &tz-abbr };
DateTime.^add_fallback:
anon sub condition ($object, $want) { $want eq 'is-dst' },
anon sub calculate ($object, $want) { &is-dst };
}
2 changes: 1 addition & 1 deletion resources/update-tz-database.raku
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ steps to be performed manually in case something is causing them problems.
First we have a few constants:
=end pod
constant $updater-version = '0.5';
constant $module-version = '0.2';
constant $module-version = '0.3';
constant TZ-DATA-URL = 'ftp://ftp.iana.org/tz/tzdata-latest.tar.gz';
constant TZ-CODE-URL = 'ftp://ftp.iana.org/tz/tzcode-latest.tar.gz';
constant TZ-ZONE-FILES = <africa antarctica asia australasia etcetera europe
Expand Down
2 changes: 1 addition & 1 deletion t/03-roundtrip.t
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use Test;
my @timezones = <Etc/GMT America/New_York America/Los_Angeles Europe/Madrid
Asia/Tokyo Africa/Kinshasa Africa/Mogadishu Asia/Qatar Asia/Singapore>;

for ^25 {
for ^10 {
my $timezone = get-timezone-data @timezones.roll;
my $in = (^1700000000).roll;

Expand Down
50 changes: 50 additions & 0 deletions t/04-upgrade.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
use Test;

# This is about the only way that it's possible for an
# old fashioned DateTime to be made. Each one will be
# tested that it upgrades properly based on its mutability
my $olson1 = BEGIN DateTime.new: now;
my \olson2 = BEGIN DateTime.new: now;
my $abbr1 = BEGIN DateTime.new: now;
my \abbr2 = BEGIN DateTime.new: now;
my $dst1 = BEGIN DateTime.new: now;
my \dst2 = BEGIN DateTime.new: now;

# All new timezones should be TimezoneAware
use DateTime::Timezones;

# First check whether mutable containers are correctly upgraded
try {
$olson1.olson-id;
ok $olson1 ~~ Timezones::TimezoneAware, "Mutable upgrade for Olson ID";
CATCH { ok False, "Mutable upgrade for Olson ID" }
}
try {
$abbr1.tz-abbr;
ok $abbr1 ~~ Timezones::TimezoneAware, "Mutable upgrade for TZ abbreviation";
CATCH { ok False, "Mutable upgrade for TZ abbreviation" }
}
try {
$dst1.is-dst;
ok $dst1 ~~ Timezones::TimezoneAware, "Mutable upgrade for DST status";
CATCH { ok False, "Mutable upgrade for DST status" }
}

# Next, check whether inmutables still provide nominally correct values
try {
olson2.olson-id;
ok olson2.olson-id ~~ Str, "Inmutable upgrade for Olson ID";
CATCH { ok False, "Inmutable upgrade for Olson ID"}
}
try {
abbr2.tz-abbr;
ok abbr2.tz-abbr ~~ Str, "Mutable upgrade for TZ abbreviation";
CATCH { ok False, "Inmutable upgrade for TZ abbreviation" }
}
try {
dst2.is-dst;
ok dst2.is-dst ~~ Bool, "Inmutable upgrade for DST status";
CATCH { ok False, "Inmutable upgrade for DST status" }
}

done-testing;

0 comments on commit 5ed8a83

Please sign in to comment.