Skip to content

Commit

Permalink
Implement Time::Location including timezone data loader
Browse files Browse the repository at this point in the history
Remove representation of floating time from `Time` (formerly expressed
as `Time::Kind::Unspecified`).

Floating time should not be represented as an instance of `Time` to avoid undefined operations through type safety (see crystal-lang#5332).
Breaking changes:
* Calls to `Time.new` and `Time.now` are now in the local time zone by
  default.
* `Time.parse`, `Time::Format.new` and `Time::Format.parse` don't specify a default location.
  If none is included in the time format and no default argument is provided, the parse method wil raise an exception because there is no way to know how such a value should be represented as an instance of `Time`.
  Applications expecting time values without time zone should provide default location to apply in such a case.
  • Loading branch information
straight-shoota committed Jan 9, 2018
1 parent 075a003 commit c1e3265
Show file tree
Hide file tree
Showing 24 changed files with 1,323 additions and 499 deletions.
Binary file added spec/std/data/zoneinfo.zip
Binary file not shown.
Binary file added spec/std/data/zoneinfo/Foo/Bar
Binary file not shown.
10 changes: 5 additions & 5 deletions spec/std/file_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -958,8 +958,8 @@ describe "File" do
filename = "#{__DIR__}/data/temp_write.txt"
File.write(filename, "")

atime = Time.new(2000, 1, 2)
mtime = Time.new(2000, 3, 4)
atime = Time.utc(2000, 1, 2)
mtime = Time.utc(2000, 3, 4)

File.utime(atime, mtime, filename)

Expand All @@ -971,8 +971,8 @@ describe "File" do
end

it "raises if file not found" do
atime = Time.new(2000, 1, 2)
mtime = Time.new(2000, 3, 4)
atime = Time.utc(2000, 1, 2)
mtime = Time.utc(2000, 3, 4)

expect_raises Errno, "Error setting time to file" do
File.utime(atime, mtime, "#{__DIR__}/nonexistent_file")
Expand All @@ -994,7 +994,7 @@ describe "File" do

it "sets file times to given time" do
filename = "#{__DIR__}/data/temp_touch.txt"
time = Time.new(2000, 3, 4)
time = Time.utc(2000, 3, 4)
begin
File.touch(filename, time)

Expand Down
14 changes: 3 additions & 11 deletions spec/std/http/http_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe HTTP do
it "parses and is local (#2744)" do
date = "Mon, 09 Sep 2011 23:36:00 -0300"
parsed_time = HTTP.parse_time(date).not_nil!
parsed_time.local?.should be_true
parsed_time.offset.should eq -3 * 3600
parsed_time.to_utc.to_s.should eq("2011-09-10 02:36:00 UTC")
end

Expand All @@ -44,16 +44,8 @@ describe HTTP do
end

it "with local time zone" do
tz = ENV["TZ"]?
ENV["TZ"] = "Europe/Berlin"
LibC.tzset
begin
time = Time.new(1994, 11, 6, 8, 49, 37, nanosecond: 0, kind: Time::Kind::Local)
HTTP.rfc1123_date(time).should eq(time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT"))
ensure
ENV["TZ"] = tz
LibC.tzset
end
time = Time.new(1994, 11, 6, 8, 49, 37, nanosecond: 0, location: Time::Location.load("Europe/Berlin"))
HTTP.rfc1123_date(time).should eq(time.to_utc.to_s("%a, %d %b %Y %H:%M:%S GMT"))
end
end

Expand Down
2 changes: 1 addition & 1 deletion spec/std/json/mapping_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ describe "JSON mapping" do
it "parses json with Time::Format converter" do
json = JSONWithTime.from_json(%({"value": "2014-10-31 23:37:16"}))
json.value.should be_a(Time)
json.value.to_s.should eq("2014-10-31 23:37:16")
json.value.to_s.should eq("2014-10-31 23:37:16 UTC")
json.to_json.should eq(%({"value":"2014-10-31 23:37:16"}))
end

Expand Down
245 changes: 245 additions & 0 deletions spec/std/time/location_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
require "spec"
require "./spec_helper"

class Time::Location
describe Time::Location do
describe ".load" do
it "loads Europe/Berlin" do
location = Location.load("Europe/Berlin")

location.name.should eq "Europe/Berlin"
standard_time = location.lookup(Time.new(2017, 11, 22))
standard_time.name.should eq "CET"
standard_time.offset.should eq 3600
standard_time.dst?.should be_false

summer_time = location.lookup(Time.new(2017, 10, 22))
summer_time.name.should eq "CEST"
summer_time.offset.should eq 7200
summer_time.dst?.should be_true

location.utc?.should be_false
location.fixed?.should be_false

with_env("TZ", nil) do
location.local?.should be_false
end

with_env("TZ", "Europe/Berlin") do
location.local?.should be_true
end

Location.load?("Europe/Berlin", Crystal::System::Time.zone_sources).should eq location
end

it "invalid timezone identifier" do
expect_raises(InvalidLocationNameError, "Foobar/Baz") do
Location.load("Foobar/Baz")
end

Location.load?("Foobar/Baz", Crystal::System::Time.zone_sources).should be_nil
end

it "treats UTC as special case" do
Location.load("UTC").should eq Location::UTC
Location.load("").should eq Location::UTC

# Etc/UTC could be pointing to anything
Location.load("Etc/UTC").should_not eq Location::UTC
end

describe "validating name" do
it "absolute path" do
expect_raises(InvalidLocationNameError) do
Location.load("/America/New_York")
end
expect_raises(InvalidLocationNameError) do
Location.load("\\Zulu")
end
end
it "dot dot" do
expect_raises(InvalidLocationNameError) do
Location.load("../zoneinfo/America/New_York")
end
expect_raises(InvalidLocationNameError) do
Location.load("a..")
end
end
end

context "with ZONEINFO" do
it "loads from custom directory" do
with_zoneinfo(File.join(__DIR__, "..", "data", "zoneinfo")) do
location = Location.load("Foo/Bar")
location.name.should eq "Foo/Bar"
end
end

it "loads from custom zipfile" do
with_zoneinfo(ZONEINFO_ZIP) do
location = Location.load("Asia/Jerusalem")
location.not_nil!.name.should eq "Asia/Jerusalem"
end
end

it "raises if not available" do
with_zoneinfo(ZONEINFO_ZIP) do
expect_raises(InvalidLocationNameError) do
Location.load("Foo/Bar")
end
Location.load?("Foo/Bar", Crystal::System::Time.zone_sources).should be_nil
end
end

it "does not fall back to default sources" do
with_zoneinfo(File.join(__DIR__, "..", "data", "zoneinfo")) do
expect_raises(InvalidLocationNameError) do
Location.load("Europe/Berlin")
end
end

with_zoneinfo("nonexising_zipfile.zip") do
expect_raises(InvalidLocationNameError) do
Location.load("Europe/Berlin")
end
end
end
end
end

it "UTC" do
location = Location::UTC
location.name.should eq "UTC"

location.utc?.should be_true
location.fixed?.should be_true

# this could fail if no source for localtime is available
unless Location.local.utc?
location.local?.should be_false
end

zone = location.lookup(Time.now)
zone.name.should eq "UTC"
zone.offset.should eq 0
zone.dst?.should be_false
end

it ".local" do
with_env("TZ", nil) do
Location.local.name.should eq "Local"
end
with_env("TZ", "Europe/Berlin") do
Location.local.name.should eq "Europe/Berlin"
end
with_env("TZ", "") do
Location.local.utc?.should be_true
end
end

describe ".fixed" do
it "accepts a name" do
location = Location.fixed("Fixed", 1800)
location.name.should eq "Fixed"
location.zones.should eq [Zone.new("Fixed", 1800, false)]
location.transitions.size.should eq 0

location.utc?.should be_false
location.fixed?.should be_true
location.local?.should be_false
end

it "positive" do
location = Location.fixed 8000
location.name.should eq "+02:13"
location.zones.first.offset.should eq 8000
end

it "ngeative" do
location = Location.fixed -7539
location.name.should eq "-02:05"
location.zones.first.offset.should eq -7539
end

it "raises if offset to large" do
expect_raises(InvalidTimezoneOffsetError, "86401") do
Location.fixed(86401)
end
expect_raises(InvalidTimezoneOffsetError, "-90000") do
Location.fixed(-90000)
end
end
end

describe "#lookup" do
it "looks up" do
with_zoneinfo do
location = Location.load("Europe/Berlin")
zone, range = location.lookup_with_boundaries(Time.utc(2017, 11, 23, 22, 6, 12).epoch)
zone.should eq Zone.new("CET", 3600, false)
range.should eq({1509238800_i64, 1521939600_i64})
end
end

it "handles dst change" do
with_zoneinfo do
location = Location.load("Europe/Berlin")
time = Time.utc(2017, 10, 29, 1, 0, 0)

summer = location.lookup(time - 1.second)
summer.name.should eq "CEST"
summer.offset.should eq 2 * SECONDS_PER_HOUR
summer.dst?.should be_true

winter = location.lookup(time)
winter.name.should eq "CET"
winter.offset.should eq 1 * SECONDS_PER_HOUR
winter.dst?.should be_false

last_ns = location.lookup(time - 1.nanosecond)
last_ns.name.should eq "CEST"
last_ns.offset.should eq 2 * SECONDS_PER_HOUR
last_ns.dst?.should be_true
end
end

it "handles value after last transition" do
with_zoneinfo do
location = Location.load("America/Buenos_Aires")
zone = location.lookup(Time.utc(5000, 1, 1))
zone.name.should eq "-03"
zone.offset.should eq -3 * 3600
end
end

# Test that we get the correct results for times before the first
# transition time. To do this we explicitly check early dates in a
# couple of specific timezones.
context "first zone" do
it "PST8PDT" do
with_zoneinfo do
location = Location.load("PST8PDT")
zone1 = location.lookup(-1633269601)
zone2 = location.lookup(-1633269601 + 1)
zone1.name.should eq "PST"
zone1.offset.should eq -8 * SECONDS_PER_HOUR
zone2.name.should eq "PDT"
zone2.offset.should eq -7 * SECONDS_PER_HOUR
end
end

it "Pacific/Fakaofo" do
with_zoneinfo do
location = Location.load("Pacific/Fakaofo")
zone1 = location.lookup(1325242799)
zone2 = location.lookup(1325242799 + 1)
zone1.name.should eq "-11"
zone1.offset.should eq -11 * SECONDS_PER_HOUR
zone2.name.should eq "+13"
zone2.offset.should eq 13 * SECONDS_PER_HOUR
end
end
end
end
end
end
17 changes: 17 additions & 0 deletions spec/std/time/spec_helper.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
def with_env(name, value)
previous = ENV[name]?
begin
ENV[name] = value
yield
ensure
ENV[name] = previous
end
end

ZONEINFO_ZIP = File.join(__DIR__, "..", "data", "zoneinfo.zip")

def with_zoneinfo(path = ZONEINFO_ZIP)
with_env("ZONEINFO", path) do
yield
end
end
Loading

0 comments on commit c1e3265

Please sign in to comment.