Skip to content

Commit d733333

Browse files
authored
Support POSIX TZ environment variable strings (#15792)
Adds support for POSIX TZ strings such as `EST5EDT,M3.2.0,M11.1.0`, as defined in [POSIX.1-2024, Section 8.3]. When the `TZ` environment variable contains such a string, `Time::Location.load_local`, and by extension `.local`, will return a location with the correct transition datetimes across the entire proleptic Gregorian calendar. A convenience method `Time::Location.posix_tz` is also added for creating those locations without going through an environment variable. The implementation-defined aspects of the standard are not supported, and can be added later at will. These include strings prefixed by a colon (`:/etc/localtime`), and TZ strings specifying a DST time zone without transition rules (`EST5EDT`, although the US ones are available in most time zone databases by those names too). `Time::Location::Zone`'s allowed offset range is slightly extended to accommodate for POSIX TZ strings. The work here can be followed up in at least two ways: * TZif database files, as defined in [IETF RFC 9636], also contain POSIX TZ strings in their footers, used to compute time zone transitions beyond the last manually defined transition time. Crystal does not read these at the moment, which means transitions after year 2038 do not work, since most tzdata packages only include entries fitting into 32-bit Unix times, even those using TZif version 2 or above. This would depend also on #11907 as the footer is located after the 64-bit Unix time body. * Windows system time zones in Crystal are defined by somewhat arbitrarily instantiating all individual transitions 100 years within the current year, but they use month + week + day of week under the hood, so they are actually compatible with POSIX TZ strings. Internally, using `Time::TZ` from this PR instead would greatly reduce the memory consumption of `Time::Location` objects on Windows. [POSIX.1-2024, Section 8.3]: https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap08.html [IETF RFC 9636]: https://datatracker.ietf.org/doc/html/rfc9636
1 parent 730db20 commit d733333

File tree

3 files changed

+763
-21
lines changed

3 files changed

+763
-21
lines changed

spec/std/time/location_spec.cr

Lines changed: 324 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
require "../spec_helper"
22
require "../../support/time"
33

4+
private def assert_tz_boundaries(tz : String, t0 : Time, t1 : Time, t2 : Time, t3 : Time, *, file = __FILE__, line = __LINE__)
5+
location = Time::Location.posix_tz("Local", tz)
6+
std_zone = location.zones.find(&.dst?.!).should_not be_nil, file: file, line: line
7+
dst_zone = location.zones.find(&.dst?).should_not be_nil, file: file, line: line
8+
t0, t1, t2, t3 = t0.to_unix, t1.to_unix, t2.to_unix, t3.to_unix
9+
10+
location.lookup_with_boundaries(t1 - 1).should eq({std_zone, {t0, t1}}), file: file, line: line
11+
location.lookup_with_boundaries(t1).should eq({dst_zone, {t1, t2}}), file: file, line: line
12+
location.lookup_with_boundaries(t1 + (t2 - t1) // 2).should eq({dst_zone, {t1, t2}}), file: file, line: line
13+
location.lookup_with_boundaries(t2 - 1).should eq({dst_zone, {t1, t2}}), file: file, line: line
14+
location.lookup_with_boundaries(t2).should eq({std_zone, {t2, t3}}), file: file, line: line
15+
end
16+
17+
private def assert_tz_raises(str, *, file = __FILE__, line = __LINE__)
18+
expect_raises(ArgumentError, "Invalid TZ string: #{str}", file: file, line: line) do
19+
Time::Location.posix_tz("", str)
20+
end
21+
end
22+
423
class Time::Location
524
describe Time::Location do
625
describe ".load" do
@@ -254,6 +273,18 @@ class Time::Location
254273
end
255274
end
256275

276+
it "with POSIX TZ string" do
277+
with_tz("EST5EDT,M3.2.0,M11.1.0") do
278+
location = Location.load_local
279+
location.name.should eq("Local")
280+
location.zones.should eq [
281+
Location::Zone.new("EST", -18000, false),
282+
Location::Zone.new("EDT", -14400, true),
283+
]
284+
location.transitions.should be_empty
285+
end
286+
end
287+
257288
{% if flag?(:win32) %}
258289
it "loads time zone information from registry" do
259290
info = LibC::DYNAMIC_TIME_ZONE_INFORMATION.new(
@@ -322,16 +353,186 @@ class Time::Location
322353
location.zones.first.offset.should eq -7539
323354
end
324355

356+
it "exactly 24 hours" do
357+
location = Location.fixed 86400
358+
location.name.should eq "+24:00"
359+
location.zones.first.offset.should eq 86400
360+
361+
location = Location.fixed -86400
362+
location.name.should eq "-24:00"
363+
location.zones.first.offset.should eq -86400
364+
end
365+
325366
it "raises if offset to large" do
326-
expect_raises(InvalidTimezoneOffsetError, "86401") do
327-
Location.fixed(86401)
367+
expect_raises(InvalidTimezoneOffsetError, "93600") do
368+
Location.fixed(93600)
328369
end
329370
expect_raises(InvalidTimezoneOffsetError, "-90000") do
330371
Location.fixed(-90000)
331372
end
332373
end
333374
end
334375

376+
describe ".tz" do
377+
it "parses string with standard time only" do
378+
location = Location.posix_tz("America/New_York", "EST5")
379+
location.name.should eq("America/New_York")
380+
location.zones.should eq [
381+
Location::Zone.new("EST", -18000, false),
382+
]
383+
location.transitions.should be_empty
384+
end
385+
386+
it "parses string with both standard time and DST" do
387+
location = Location.posix_tz("America/New_York", "EST5EDT,M3.2.0,M11.1.0")
388+
location.name.should eq("America/New_York")
389+
location.zones.should eq [
390+
Location::Zone.new("EST", -18000, false),
391+
Location::Zone.new("EDT", -14400, true),
392+
]
393+
location.transitions.should be_empty
394+
395+
location = Location.posix_tz("America/New_York", "EST5EDT-24:59:59,M3.2.0,M11.1.0")
396+
location.name.should eq("America/New_York")
397+
location.zones.should eq [
398+
Location::Zone.new("EST", -18000, false),
399+
Location::Zone.new("EDT", 89999, true),
400+
]
401+
location.transitions.should be_empty
402+
403+
location = Location.posix_tz("America/New_York", "EST-24:59:59EDT,M3.2.0,M11.1.0")
404+
location.name.should eq("America/New_York")
405+
location.zones.should eq [
406+
Location::Zone.new("EST", 89999, false),
407+
Location::Zone.new("EDT", 93599, true),
408+
]
409+
location.transitions.should be_empty
410+
end
411+
412+
it "parses string with all-year DST" do
413+
location = Location.posix_tz("America/New_York", "EST5EDT,0/0,J365/25")
414+
location.name.should eq("America/New_York")
415+
location.zones.should eq [
416+
Location::Zone.new("EDT", -14400, true),
417+
]
418+
location.transitions.should be_empty
419+
420+
location = Location.posix_tz("America/New_York", "XXX-6EDT-4:30:10,J1/0,J365/22:30:10")
421+
location.name.should eq("America/New_York")
422+
location.zones.should eq [
423+
Location::Zone.new("EDT", 16210, true),
424+
]
425+
location.transitions.should be_empty
426+
end
427+
428+
it "errors on invalid TZ strings" do
429+
# std
430+
assert_tz_raises ""
431+
assert_tz_raises "G"
432+
assert_tz_raises "GM"
433+
assert_tz_raises "<>"
434+
assert_tz_raises "<G>"
435+
assert_tz_raises "<GM>"
436+
assert_tz_raises "<GMT"
437+
assert_tz_raises "012"
438+
assert_tz_raises "+00"
439+
assert_tz_raises "-00"
440+
assert_tz_raises "<$aa>"
441+
assert_tz_raises "?"
442+
assert_tz_raises ":foobar"
443+
assert_tz_raises "/foo/bar"
444+
assert_tz_raises "Europe/Berlin"
445+
446+
# std offset
447+
assert_tz_raises "EST"
448+
assert_tz_raises "EST "
449+
assert_tz_raises "EST 5"
450+
assert_tz_raises "EST25"
451+
assert_tz_raises "EST123"
452+
assert_tz_raises "EST00123"
453+
assert_tz_raises "EST-25"
454+
assert_tz_raises "EST-123"
455+
assert_tz_raises "EST-00123"
456+
assert_tz_raises "EST4:"
457+
assert_tz_raises "EST4:60"
458+
assert_tz_raises "EST4:+30"
459+
assert_tz_raises "EST4:-01"
460+
assert_tz_raises "EST4:20:"
461+
assert_tz_raises "EST4:20:60"
462+
assert_tz_raises "EST4:20:+30"
463+
assert_tz_raises "EST4:20:-01"
464+
465+
# dst
466+
assert_tz_raises "EST5 "
467+
assert_tz_raises "EST5G"
468+
assert_tz_raises "EST5GM"
469+
assert_tz_raises "EST5<>"
470+
assert_tz_raises "EST5<GM>"
471+
assert_tz_raises "EST5<GMT"
472+
assert_tz_raises "EST5<$aa>"
473+
assert_tz_raises "EST5+00"
474+
assert_tz_raises "EST5-00"
475+
476+
# dst offset
477+
assert_tz_raises "EST5EDT4:"
478+
assert_tz_raises "EST5EDT4:60"
479+
assert_tz_raises "EST5EDT4:+30"
480+
assert_tz_raises "EST5EDT4:-01"
481+
assert_tz_raises "EST5EDT4:20:"
482+
assert_tz_raises "EST5EDT4:20:60"
483+
assert_tz_raises "EST5EDT4:20:+30"
484+
assert_tz_raises "EST5EDT4:20:-01"
485+
486+
# start
487+
assert_tz_raises "EST5EDT"
488+
assert_tz_raises "EST5EDT,"
489+
assert_tz_raises "EST5EDT,A"
490+
assert_tz_raises "EST5EDT,J0"
491+
assert_tz_raises "EST5EDT,J366"
492+
assert_tz_raises "EST5EDT,-1"
493+
assert_tz_raises "EST5EDT,366"
494+
assert_tz_raises "EST5EDT,M3"
495+
assert_tz_raises "EST5EDT,M3."
496+
assert_tz_raises "EST5EDT,M3.2"
497+
assert_tz_raises "EST5EDT,M3.2."
498+
assert_tz_raises "EST5EDT,M0.2.0"
499+
assert_tz_raises "EST5EDT,M13.2.0"
500+
assert_tz_raises "EST5EDT,M3.0.0"
501+
assert_tz_raises "EST5EDT,M3.6.0"
502+
assert_tz_raises "EST5EDT,M3.2.-1"
503+
assert_tz_raises "EST5EDT,M3.2.7"
504+
assert_tz_raises "EST5EDT,M3.2.0/"
505+
assert_tz_raises "EST5EDT,M3.2.0/168"
506+
assert_tz_raises "EST5EDT,M3.2.0/-168"
507+
508+
# end
509+
assert_tz_raises "EST5EDT,M3.2.0"
510+
assert_tz_raises "EST5EDT,M3.2.0,"
511+
assert_tz_raises "EST5EDT,M3.2.0,A"
512+
assert_tz_raises "EST5EDT,M3.2.0,J0"
513+
assert_tz_raises "EST5EDT,M3.2.0,J366"
514+
assert_tz_raises "EST5EDT,M3.2.0,-1"
515+
assert_tz_raises "EST5EDT,M3.2.0,366"
516+
assert_tz_raises "EST5EDT,M3.2.0,M11"
517+
assert_tz_raises "EST5EDT,M3.2.0,M11."
518+
assert_tz_raises "EST5EDT,M3.2.0,M11.1"
519+
assert_tz_raises "EST5EDT,M3.2.0,M11.1."
520+
assert_tz_raises "EST5EDT,M3.2.0,M0.1.0"
521+
assert_tz_raises "EST5EDT,M3.2.0,M13.1.0"
522+
assert_tz_raises "EST5EDT,M3.2.0,M11.0.0"
523+
assert_tz_raises "EST5EDT,M3.2.0,M11.6.0"
524+
assert_tz_raises "EST5EDT,M3.2.0,M11.1.-1"
525+
assert_tz_raises "EST5EDT,M3.2.0,M11.1.7"
526+
assert_tz_raises "EST5EDT,M3.2.0,M11.1.0/"
527+
assert_tz_raises "EST5EDT,M3.2.0,M11.1.0/168"
528+
assert_tz_raises "EST5EDT,M3.2.0,M11.1.0/-168"
529+
530+
# trailing characters
531+
assert_tz_raises "EST5EDT,M3.2.0,M11.1.0 "
532+
assert_tz_raises "EST5EDT,M3.2.0/2,M11.1.0/2 "
533+
end
534+
end
535+
335536
describe "#lookup" do
336537
it "looks up" do
337538
with_zoneinfo do
@@ -428,6 +629,127 @@ class Time::Location
428629
location.lookup(Time.utc(2017, 11, 23, 22, 6, 12)).should eq cached_zone
429630
end
430631
end
632+
633+
context "TZ string" do
634+
it "looks up location with standard time only" do
635+
location = Location.posix_tz("Local", "EST5")
636+
zone, range = location.lookup_with_boundaries(Time.utc(2025, 1, 1, 22, 6, 12).to_unix)
637+
zone.should eq(Zone.new("EST", -18000, false))
638+
range.should eq({Int64::MIN, Int64::MAX})
639+
end
640+
641+
it "looks up location with all-year DST" do
642+
location = Location.posix_tz("Local", "EST5EDT4,0/0,J365/25")
643+
zone, range = location.lookup_with_boundaries(Time.utc(2025, 1, 1, 22, 6, 12).to_unix)
644+
zone.should eq(Zone.new("EDT", -14400, true))
645+
range.should eq({Int64::MIN, Int64::MAX})
646+
end
647+
648+
context "transition dates" do
649+
it "supports one-based ordinal days" do
650+
assert_tz_boundaries "EST5EDT4,J1/2,J365/2",
651+
Time.utc(2025, 12, 31, 6, 0, 0), Time.utc(2026, 1, 1, 7, 0, 0),
652+
Time.utc(2026, 12, 31, 6, 0, 0), Time.utc(2027, 1, 1, 7, 0, 0)
653+
654+
assert_tz_boundaries "EST5EDT4,J1/2,J365/2",
655+
Time.utc(2027, 12, 31, 6, 0, 0), Time.utc(2028, 1, 1, 7, 0, 0),
656+
Time.utc(2028, 12, 31, 6, 0, 0), Time.utc(2029, 1, 1, 7, 0, 0)
657+
end
658+
659+
it "excludes Feb 29 if one-based" do
660+
assert_tz_boundaries "EST5EDT4,J59/2,J60/2",
661+
Time.utc(2027, 3, 1, 6, 0, 0), Time.utc(2028, 2, 28, 7, 0, 0),
662+
Time.utc(2028, 3, 1, 6, 0, 0), Time.utc(2029, 2, 28, 7, 0, 0)
663+
end
664+
665+
it "supports zero-based ordinal days" do
666+
assert_tz_boundaries "EST5EDT4,50/2,280/2",
667+
Time.utc(2025, 10, 8, 6, 0, 0), Time.utc(2026, 2, 20, 7, 0, 0),
668+
Time.utc(2026, 10, 8, 6, 0, 0), Time.utc(2027, 2, 20, 7, 0, 0)
669+
670+
assert_tz_boundaries "EST5EDT4,50/2,280/2",
671+
Time.utc(2027, 10, 8, 6, 0, 0), Time.utc(2028, 2, 20, 7, 0, 0),
672+
Time.utc(2028, 10, 7, 6, 0, 0), Time.utc(2029, 2, 20, 7, 0, 0)
673+
end
674+
675+
it "includes Feb 29 if zero-based" do
676+
assert_tz_boundaries "EST5EDT4,59/2,60/2",
677+
Time.utc(2027, 3, 2, 6, 0, 0), Time.utc(2028, 2, 29, 7, 0, 0),
678+
Time.utc(2028, 3, 1, 6, 0, 0), Time.utc(2029, 3, 1, 7, 0, 0)
679+
end
680+
681+
it "supports month + week + day of week" do
682+
tz = "EST5EDT4,M3.2.0/2,M11.1.0/2"
683+
684+
trans = [
685+
{Time.utc(2020, 11, 1, 6, 0, 0), Time.utc(2021, 3, 14, 7, 0, 0)},
686+
{Time.utc(2021, 11, 7, 6, 0, 0), Time.utc(2022, 3, 13, 7, 0, 0)},
687+
{Time.utc(2022, 11, 6, 6, 0, 0), Time.utc(2023, 3, 12, 7, 0, 0)},
688+
{Time.utc(2023, 11, 5, 6, 0, 0), Time.utc(2024, 3, 10, 7, 0, 0)},
689+
{Time.utc(2024, 11, 3, 6, 0, 0), Time.utc(2025, 3, 9, 7, 0, 0)},
690+
{Time.utc(2025, 11, 2, 6, 0, 0), Time.utc(2026, 3, 8, 7, 0, 0)},
691+
{Time.utc(2026, 11, 1, 6, 0, 0), Time.utc(2027, 3, 14, 7, 0, 0)},
692+
{Time.utc(2027, 11, 7, 6, 0, 0), Time.utc(2028, 3, 12, 7, 0, 0)},
693+
{Time.utc(2028, 11, 5, 6, 0, 0), Time.utc(2029, 3, 11, 7, 0, 0)},
694+
]
695+
696+
trans.each_cons_pair do |(t0, t1), (t2, t3)|
697+
assert_tz_boundaries(tz, t0, t1, t2, t3)
698+
end
699+
end
700+
701+
it "handles time zone differences other than 1 hour" do
702+
assert_tz_boundaries "EST4:30EDT-1:23:45,M3.2.0,M11.1.0",
703+
Time.utc(2024, 11, 3, 0, 36, 15), Time.utc(2025, 3, 9, 6, 30, 0),
704+
Time.utc(2025, 11, 2, 0, 36, 15), Time.utc(2026, 3, 8, 6, 30, 0)
705+
end
706+
707+
it "defaults transition times to 02:00:00 local time" do
708+
assert_tz_boundaries "EST5EDT,M3.2.0,M11.1.0",
709+
Time.utc(2024, 11, 3, 6, 0, 0), Time.utc(2025, 3, 9, 7, 0, 0),
710+
Time.utc(2025, 11, 2, 6, 0, 0), Time.utc(2026, 3, 8, 7, 0, 0)
711+
end
712+
713+
it "supports transition times from -167 to 167 hours" do
714+
assert_tz_boundaries "EST5EDT,M3.2.0/-167,M11.1.0/167",
715+
Time.utc(2024, 11, 10, 3, 0, 0), Time.utc(2025, 3, 2, 6, 0, 0),
716+
Time.utc(2025, 11, 9, 3, 0, 0), Time.utc(2026, 3, 1, 6, 0, 0)
717+
end
718+
719+
it "handles years beginning and ending in DST" do
720+
tz = "AEST-10AEDT,M10.1.0,M4.1.0/3"
721+
722+
trans = [
723+
{Time.utc(2020, 4, 4, 16, 0, 0), Time.utc(2020, 10, 3, 16, 0, 0)},
724+
{Time.utc(2021, 4, 3, 16, 0, 0), Time.utc(2021, 10, 2, 16, 0, 0)},
725+
{Time.utc(2022, 4, 2, 16, 0, 0), Time.utc(2022, 10, 1, 16, 0, 0)},
726+
{Time.utc(2023, 4, 1, 16, 0, 0), Time.utc(2023, 9, 30, 16, 0, 0)},
727+
{Time.utc(2024, 4, 6, 16, 0, 0), Time.utc(2024, 10, 5, 16, 0, 0)},
728+
{Time.utc(2025, 4, 5, 16, 0, 0), Time.utc(2025, 10, 4, 16, 0, 0)},
729+
{Time.utc(2026, 4, 4, 16, 0, 0), Time.utc(2026, 10, 3, 16, 0, 0)},
730+
{Time.utc(2027, 4, 3, 16, 0, 0), Time.utc(2027, 10, 2, 16, 0, 0)},
731+
{Time.utc(2028, 4, 1, 16, 0, 0), Time.utc(2028, 9, 30, 16, 0, 0)},
732+
{Time.utc(2029, 3, 31, 16, 0, 0), Time.utc(2029, 10, 6, 16, 0, 0)},
733+
]
734+
735+
trans.each_cons_pair do |(t0, t1), (t2, t3)|
736+
assert_tz_boundaries(tz, t0, t1, t2, t3)
737+
end
738+
end
739+
740+
it "handles very distant years" do
741+
assert_tz_boundaries "EST5EDT4,M3.2.0/2,M11.1.0/2",
742+
Time.utc(1583, 11, 6, 6, 0, 0), Time.utc(1584, 3, 11, 7, 0, 0),
743+
Time.utc(1584, 11, 4, 6, 0, 0), Time.utc(1585, 3, 10, 7, 0, 0)
744+
745+
assert_tz_boundaries "EST5EDT4,M3.2.0/2,M11.1.0/2",
746+
Time.utc(3332, 11, 2, 6, 0, 0), Time.utc(3333, 3, 8, 7, 0, 0),
747+
Time.utc(3333, 11, 1, 6, 0, 0), Time.utc(3334, 3, 14, 7, 0, 0)
748+
end
749+
end
750+
end
751+
752+
pending "zoneinfo + POSIX TZ string"
431753
end
432754
end
433755

0 commit comments

Comments
 (0)