diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..6b9fde3 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,51 @@ +version: 2 + +defaults: &defaults + working_directory: ~/repo + environment: + LC_ALL: C.UTF-8 + +install_hex_rebar: &install_hex_rebar + run: + name: Install hex and rebar + command: | + mix local.hex --force + mix local.rebar --force + +install_system_deps: &install_system_deps + run: + name: Install system dependencies + command: | + apk add build-base lzip + +jobs: + build_elixir_1_11_otp_23: + docker: + - image: hexpm/elixir:1.11.3-erlang-23.2.2-alpine-3.12.1 + <<: *defaults + steps: + - checkout + - <<: *install_hex_rebar + - <<: *install_system_deps + - restore_cache: + keys: + - v1-mix-cache-{{ checksum "mix.lock" }} + - run: mix deps.get + - run: mix format --check-formatted + - run: mix deps.unlock --check-unused + - run: mix compile --warnings-as-errors + - run: mix docs + - run: mix hex.build + - run: mix test + - run: mix dialyzer + - save_cache: + key: v1-mix-cache-{{ checksum "mix.lock" }} + paths: + - _build + - deps + +workflows: + version: 2 + build_test: + jobs: + - build_elixir_1_11_otp_23 diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffe771f --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +zoneinfo-*.tar + + +# Temporary files for e.g. tests +/tmp + +# Downloaded tzdb archive +/tzdb-*.tar.lz diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e54cdd7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## v0.1.0 + +Initial release to hex. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ee53724 --- /dev/null +++ b/Makefile @@ -0,0 +1,87 @@ +# Makefile for building the test database +# +# Makefile targets: +# +# all build and install the database +# clean clean build products and intermediates +# +# Variables to override: +# +# MIX_APP_PATH path to the build directory +# CC_FOR_BUILD C compiler + +# Since this is for test purposes, be sure this matches what the tz (or tzdata) +# libraries use or you'll get discrepancies that are ok. +TZDB_VERSION=2020f +TZDB_NAME=tzdb-$(TZDB_VERSION) +TZDB_FILENAME=$(TZDB_NAME).tar.lz +TZDB_URL=https://data.iana.org/time-zones/releases/$(TZDB_FILENAME) + +# Specifying dates that resemble things for humans is hard +# in Makefiles aparently. The following go from 1940 to 2038. +# Sometimes `date -d 1940-01-01T00:00:00 "+%s"` works. +FROM_DATE_EPOCH=-946753200 +TO_DATE_EPOCH=2147483648 +ZIC_OPTIONS=-r @$(FROM_DATE_EPOCH)/@$(TO_DATE_EPOCH) +#ZIC_OPTIONS=-r @0/@2147483648 + +PREFIX = $(MIX_APP_PATH)/priv +BUILD = $(MIX_APP_PATH)/obj +CC_FOR_BUILD=cc + +calling_from_make: + mix compile + +all: $(PREFIX)/zoneinfo + +### Copied from tzcode Makefile + +# Package name for the code distribution. +PACKAGE= tzcode + +# Version number for the distribution, overridden in the 'tarballs' rule below. +VERSION= unknown + +# Email address for bug reports. +BUGEMAIL= tz@iana.org + +# Backwards compatibility +BACKWARD= backward + +# Everything that's normally installed +PRIMARY_YDATA= africa antarctica asia australasia \ + europe northamerica southamerica +YDATA= $(PRIMARY_YDATA) etcetera +NDATA= factory +TDATA= $(YDATA) + +$(BUILD)/tzdb/version.h: $(BUILD)/tzdb/version + VERSION=`cat $(BUILD)/tzdb/version` && printf '%s\n' \ + 'static char const PKGVERSION[]="($(PACKAGE)) ";' \ + "static char const TZVERSION[]=\"$$VERSION\";" \ + 'static char const REPORT_BUGS_TO[]="$(BUGEMAIL)";' \ + >$@.out + mv $@.out $@ + +### End copied definitions + +$(BUILD)/tzdb/zic: $(BUILD)/tzdb $(BUILD)/tzdb/zic.c $(BUILD)/tzdb/version.h + $(CC_FOR_BUILD) -o $@ $(BUILD)/tzdb/zic.c + +$(PREFIX)/zoneinfo: $(BUILD)/tzdb/zic $(PREFIX) Makefile + cd $(BUILD)/tzdb && ./zic -d $@ $(ZIC_OPTIONS) $(TDATA) + +$(TZDB_FILENAME): + wget $(TZDB_URL) + +$(BUILD)/tzdb: $(TZDB_FILENAME) $(BUILD) + cd $(BUILD) && lzip -d -c $(PWD)/$(TZDB_FILENAME) | tar x + mv $(BUILD)/$(TZDB_NAME) $@ + +$(PREFIX) $(BUILD): + mkdir -p $@ + +clean: + $(RM) -r $(BUILD) $(PREFIX) + +.PHONY: all clean calling_from_make diff --git a/README.md b/README.md new file mode 100644 index 0000000..443651c --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Zoneinfo + +[![Hex version](https://img.shields.io/hexpm/v/zoneinfo.svg "Hex version")](https://hex.pm/packages/zoneinfo) +[![CircleCI](https://circleci.com/gh/smartrent/zoneinfo.svg?style=svg)](https://circleci.com/gh/smartrent/zoneinfo) + +Elixir time zone support for your OS-supplied zoneinfo files + +Why Zoneinfo? + +* Reuse your OS-maintained time zone database (usually in `/usr/share/zoneinfo`) +* Reduce your OTP release size by not bundling time zone data +* Load other [TZif](https://tools.ietf.org/html/rfc8636) files + +Why not Zoneinfo? + +* [`tzdata`](http://hex.pm/packages/tzdata) and + [`tz`](http://hex.pm/packages/tz) work fine for you +* You can't rely on your OS to update time zone files and don't want to + implement this yourself +* You're running on Windows (the zoneinfo database can still be installed, but + this is not a typical configuration) +* You need to extrapolate time zone conversions far in the future or past. + Zoneinfo currently is limited to the ranges in TZif files which typically go + to 2038. +* Speed is of utmost importance. Zoneinfo loads time zones on demand and caches + them, but it does not focus on performance like `tz`. + +Zoneinfo is tested for consistency against the `tz` library. It's possible to +test against `tzdata` by modifying `@truth` in the unit tests. All libraries +source their information from the [IANA Time Zone +Database](http://www.iana.org/time-zones) + +## Installation + +First, add `:zoneinfo` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:zoneinfo, "~> 0.1.0"} + ] +end +``` + +Next, decide whether you want to configure Elixir to use Zoneinfo as the default +time zone lookup. If you do, add the following line to your `config.exs`: + +```elixir +config :elixir, time_zone_database: Zoneinfo.TimeZoneDatabase +``` + +or call `Calendar.put_time_zone_database/1`: + +```elixir +Calendar.put_time_zone_database(Zoneinfo.TimeZoneDatabase) +``` + +It's also possible to pass `Zoneinfo.TimeZoneDatabase` to `DateTime` functions to +avoid the global configuration. + +The final step is to specify the location of the time zone files. Zoneinfo looks +at the following locations: + +1. The `:tzpath` key in the application environment +2. The `TZPATH` environment variable +3. `/usr/share/zoneinfo` + +Since `/usr/share/zoneinfo` is the default on Linux and OSX, you may not need to +do anything. + +To set `:tzpath` in the application environment, add this line to your +`config.exs`: + +```elixir +config :zoneinfo, tzpath: "/custom/location" +``` + +## Notes and caveats + +### Caching + +While Zoneinfo does not contain a database and therefore has no logic to pull +updates, it does cache data in memory for better performance. It flushes the +cache daily so that it's possible to pick up changes to the system timezone +data. + +### Date ranges + +Zoneinfo uses the date ranges stored in the TZif data for determining time zone +information. While TZif files support extrapolation of dates beyond what's +stored, Zoneinfo currently does not use it. This means that dates far enough in +the future won't be calculated correctly. + +The default end date from the time zone compiler, +[zic(8)](https://data.iana.org/time-zones/tzdb/zic.8.txt), is 2038. This could, +of course, could change and one would hope that it would be pushed farther out +rather than reduced since the files are already pretty small. + +If you're looking at creating the smallest possible time zone database for and +embedded system, using `zic`'s `-r` flag helps significantly, but make sure that +you have enough buffer to avoid extrapolation. + +### Unit tests + +The tests currently take a long time to run since they're checking a LOT of +dates and times. If you're working on a patch, you may want to limit the date +range in `time_zone_database_test.exs` to 10 years or less. + +## Acknowledgments + +Both [`tz`](http://hex.pm/packages/tz) and +[`tzdata`](http://hex.pm/packages/tzdata) were both extremely helpful in +answering time zone questions. Code in this library will almost certainly look +like it was influenced from the two libraries. Additionally, being able to +compare the output of Zoneinfo to the output of those libraries caught a few +subtle time zone handling bugs that could easily have gone unnoticed. The [IANA +time zone rules database comments](https://www.iana.org/time-zones) and +[timeanddate.com](https://www.timeanddate.com/time/change/) were also extremely +helpful to resolve discrepancies. diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..789dfd5 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,13 @@ +import Config + +config :elixir, time_zone_database: Zoneinfo.TimeZoneDatabase + +# Comment out this line to test with the OS database +config :zoneinfo, tzpath: Path.expand(Path.join(Mix.Project.compile_path(), "../priv/zoneinfo")) + +# Turn off autoupdate on tzdata since if we're comparing our results with its +# results (See @truth in unit tests), we don't want it updating its database to +# something newer. See the Makefile for which timezone the unit tests use. +config :tzdata, :autoupdate, :disabled + +config :tz, build_time_zone_periods_with_ongoing_dst_changes_until_year: 2039 diff --git a/lib/zoneinfo.ex b/lib/zoneinfo.ex new file mode 100644 index 0000000..2cbc30c --- /dev/null +++ b/lib/zoneinfo.ex @@ -0,0 +1,70 @@ +defmodule Zoneinfo do + @moduledoc """ + Elixir time zone support for your OS-supplied time zone database + + Tell Elixir to use this as the default time zone database by running: + + ```elixir + Calendar.put_time_zone_database(Zoneinfo.TimeZoneDatabase) + ``` + + Time zone data is loaded from the path returned by `tzpath/0`. The default + is to use `/usr/share/zoneinfo`, but that may be changed by setting the + `$TZPATH` environment or adding the following to your project's `config.exs`: + + ```elixir + config :zoneinfo, tzpath: "/custom/location" + ``` + + Call `time_zones/0` to get the list of supported time zones. + """ + + @doc """ + Return all known time zones + + This function scans the path returned by `tzpath/0` for all time zones and + performs a basic check on each file. It may not be fast. It will not return + the aliases that zoneinfo uses for backwards compatibility even though they + may still work. + """ + @spec time_zones() :: [String.t()] + def time_zones() do + path = Path.expand(tzpath()) + + Path.join(path, "**") + |> Path.wildcard() + # Filter out directories and symlinks to old names of time zones + |> Enum.filter(fn f -> File.lstat!(f, time: :posix).type == :regular end) + # Filter out anything that doesn't look like a TZif file + |> Enum.filter(&contains_tzif?/1) + # Fix up the remaining paths to look like time zones + |> Enum.map(&String.replace_leading(&1, path <> "/", "")) + end + + @doc """ + Return the path to the time zone files + """ + @spec tzpath() :: binary() + def tzpath() do + with nil <- Application.get_env(:zoneinfo, :tzpath), + nil <- System.get_env("TZPATH") do + "/usr/share/zoneinfo" + end + end + + defp contains_tzif?(path) do + case File.open(path, [:read], &contains_tzif_helper/1) do + {:ok, result} -> result + _error -> false + end + end + + defp contains_tzif_helper(io) do + with buff when is_binary(buff) and byte_size(buff) == 8 <- IO.binread(io, 8), + {:ok, _version} <- Zoneinfo.TZif.version(buff) do + true + else + _anything -> false + end + end +end diff --git a/lib/zoneinfo/application.ex b/lib/zoneinfo/application.ex new file mode 100644 index 0000000..402be64 --- /dev/null +++ b/lib/zoneinfo/application.ex @@ -0,0 +1,15 @@ +defmodule Zoneinfo.Application do + @moduledoc false + + use Application + + @impl Application + def start(_type, _args) do + children = [ + {Zoneinfo.Cache, []} + ] + + opts = [strategy: :one_for_one, name: Zoneinfo.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/zoneinfo/cache.ex b/lib/zoneinfo/cache.ex new file mode 100644 index 0000000..ed64913 --- /dev/null +++ b/lib/zoneinfo/cache.ex @@ -0,0 +1,86 @@ +defmodule Zoneinfo.Cache do + use GenServer + + @moduledoc false + + @table __MODULE__ + @ttl_seconds 60 * 60 * 24 + + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(args) do + GenServer.start_link(__MODULE__, args, name: __MODULE__) + end + + @doc """ + Return the information for a time_zone + """ + # @spec get(binary) :: {:ok, Zoneinfo.TZif.t()} | {:error, File.posix() | :invalid} + def get(time_zone) do + case :ets.lookup(@table, time_zone) do + [{^time_zone, tzif}] -> + {:ok, tzif} + + _ -> + GenServer.call(__MODULE__, {:load, time_zone}) + end + rescue + ArgumentError -> + # GenServer crashed. Try to load manually. + load_time_zone(time_zone) + end + + @impl GenServer + def init(_args) do + @table = :ets.new(@table, [:set, :protected, :named_table]) + gc() + + {:ok, nil} + end + + @impl GenServer + def handle_call({:load, time_zone}, _from, state) do + result = load_time_zone(time_zone) + + case result do + {:ok, tzif} -> + :ets.insert(@table, {time_zone, tzif}) + + _ -> + :ok + end + + {:reply, result, state} + end + + @impl GenServer + def handle_info(:gc, state) do + gc() + {:noreply, state} + end + + defp gc() do + # Everything gets erased at the same time to keep this simple + :ets.delete_all_objects(@table) + Process.send_after(self(), :gc, @ttl_seconds * 1000) + end + + defp load_time_zone(time_zone) when is_binary(time_zone) do + Zoneinfo.tzpath() + |> Path.join(time_zone) + |> File.open(&load_tzif/1) + |> case do + {:ok, result} -> result + error -> error + end + end + + defp load_tzif(io) do + with header when is_binary(header) and byte_size(header) == 8 <- IO.binread(io, 8), + {:ok, _version} <- Zoneinfo.TZif.version(header), + rest <- IO.binread(io, :all) do + Zoneinfo.TZif.parse(header <> rest) + else + _error -> {:error, :invalid} + end + end +end diff --git a/lib/zoneinfo/time_zone_database.ex b/lib/zoneinfo/time_zone_database.ex new file mode 100644 index 0000000..0db0764 --- /dev/null +++ b/lib/zoneinfo/time_zone_database.ex @@ -0,0 +1,74 @@ +defmodule Zoneinfo.TimeZoneDatabase do + @moduledoc """ + Calendar.TimeZoneDatabase implementation for Zoneinfo + + Pass this module to the `DateTime` functions or set it as the default by + calling `Calendar.put_time_zone_database/1` + """ + + @behaviour Calendar.TimeZoneDatabase + import Zoneinfo.Utils + + @impl Calendar.TimeZoneDatabase + def time_zone_period_from_utc_iso_days(iso_days, time_zone) do + with {:ok, tzif} <- Zoneinfo.Cache.get(time_zone) do + iso_days_to_gregorian_seconds(iso_days) + |> find_period_for_utc_secs(tzif.periods) + end + end + + @impl Calendar.TimeZoneDatabase + def time_zone_periods_from_wall_datetime(naive_datetime, time_zone) do + with {:ok, tzif} <- Zoneinfo.Cache.get(time_zone) do + {seconds, _micros} = NaiveDateTime.to_gregorian_seconds(naive_datetime) + find_period_for_wall_secs(seconds, tzif.periods) + end + end + + defp find_period_for_utc_secs(secs, periods) do + period = Enum.find(periods, fn {time, _, _, _} -> secs >= time end) + {:ok, period_to_map(period)} + end + + # receives wall gregorian seconds (also referred as the 'given timestamp' in the comments below) + # and the list of transitions + defp find_period_for_wall_secs(_, [period]), do: {:ok, period_to_map(period)} + + defp find_period_for_wall_secs(wall_secs, [ + period = {utc_secs, utc_off, std_off, _}, + prev_period = {_ts2, prev_utc_off, prev_std_off, _} + | tail + ]) do + period_start_wall_secs = utc_secs + utc_off + std_off + prev_period_end_wall_secs = utc_secs + prev_utc_off + prev_std_off + + case {wall_secs >= period_start_wall_secs, wall_secs >= prev_period_end_wall_secs} do + {false, false} -> + # Try next earlier period + find_period_for_wall_secs(wall_secs, [prev_period | tail]) + + {true, true} -> + # Contained in this period + {:ok, period_to_map(period)} + + {false, true} -> + # Time leaped forward and this is in the gap between periods + {:gap, + {period_to_map(prev_period), + gregorian_seconds_to_naive_datetime(prev_period_end_wall_secs)}, + {period_to_map(period), gregorian_seconds_to_naive_datetime(period_start_wall_secs)}} + + {true, false} -> + # Time fell back and this is in both periods + {:ambiguous, period_to_map(prev_period), period_to_map(period)} + end + end + + defp period_to_map({_timestamp, utc_off, std_off, abbr}) do + %{ + utc_offset: utc_off, + std_offset: std_off, + zone_abbr: abbr + } + end +end diff --git a/lib/zoneinfo/tzif.ex b/lib/zoneinfo/tzif.ex new file mode 100644 index 0000000..79ab07e --- /dev/null +++ b/lib/zoneinfo/tzif.ex @@ -0,0 +1,251 @@ +defmodule Zoneinfo.TZif do + @magic "TZif" + + @moduledoc false + + @typedoc """ + {from, ut_offset, name, std or dst, wall or std, local or ut} + """ + @type period() :: + {integer(), Calendar.utc_offset(), Calendar.std_offset(), Calendar.zone_abbr()} + + defstruct [:version, :periods, :tz_string] + @type t() :: %__MODULE__{version: 1..3, periods: period()} + + @doc """ + Parse TZif data + """ + # @spec parse(binary()) :: {:ok, t()} | {:error, :invalid} + def parse(data) when is_binary(data) do + token = {%__MODULE__{}, data} + + case version(data) do + {:ok, 1} -> + token + |> parse_v1_data() + |> format_result() + + {:ok, _two_plus} -> + token + |> skip_v1_data() + |> parse_v2_data() + |> parse_footer() + |> format_result() + + error -> + error + end + end + + defp format_result({nil, _rest}), do: {:error, :invalid} + defp format_result({result, _rest}), do: {:ok, result} + + @doc """ + Return the TZif version + """ + @spec version(any) :: {:error, :invalid} | {:ok, 1..9} + def version(<<@magic, 0, _rest::binary>>), do: {:ok, 1} + + def version(<<@magic, version, _rest::binary>>) when version >= ?2 and version <= ?9, + do: {:ok, version - ?0} + + def version(_anything_else) do + {:error, :invalid} + end + + defp parse_v1_data( + {tzif, + <<@magic, _version, _unused::15-bytes, isutcnt::32, isstdcnt::32, leapcnt::32, + timecnt::32, typecnt::32, charcnt::32, + transition_times::unit(32)-size(timecnt)-binary, + transition_types::unit(8)-size(timecnt)-binary, + local_time_types::unit(48)-size(typecnt)-binary, + time_zone_designations::unit(8)-size(charcnt)-binary, + _leap_second_records::unit(64)-size(leapcnt)-binary, + standard_indicators::unit(8)-size(isstdcnt)-binary, + ut_indicators::unit(8)-size(isutcnt)-binary, rest::binary()>>} + ) do + new_tzif = %{ + tzif + | version: 1, + periods: + decode_transition_times( + 32, + transition_times, + transition_types, + local_time_types, + time_zone_designations, + standard_indicators, + ut_indicators + ) + } + + {new_tzif, rest} + end + + defp parse_v1_data(_other) do + {nil, nil} + end + + defp skip_v1_data( + {tzif, + <<@magic, _version, _unused::15-bytes, isutcnt::32, isstdcnt::32, leapcnt::32, + timecnt::32, typecnt::32, charcnt::32, + _transition_times::unit(32)-size(timecnt)-binary, + _transition_types::unit(8)-size(timecnt)-binary, + _local_time_types::unit(48)-size(typecnt)-binary, + _time_zone_designations::unit(8)-size(charcnt)-binary, + _leap_second_records::unit(64)-size(leapcnt)-binary, + _standard_indicators::unit(8)-size(isstdcnt)-binary, + _ut_indicators::unit(8)-size(isutcnt)-binary, rest::binary()>>} + ) do + {tzif, rest} + end + + defp skip_v1_data(_other) do + {nil, nil} + end + + defp parse_v2_data( + {tzif, + <<@magic, version, _unused::15-bytes, isutcnt::32, isstdcnt::32, leapcnt::32, + timecnt::32, typecnt::32, charcnt::32, + transition_times::unit(64)-size(timecnt)-binary, + transition_types::unit(8)-size(timecnt)-binary, + local_time_types::unit(48)-size(typecnt)-binary, + time_zone_designations::unit(8)-size(charcnt)-binary, + _leap_second_records::unit(96)-size(leapcnt)-binary, + standard_indicators::unit(8)-size(isstdcnt)-binary, + ut_indicators::unit(8)-size(isutcnt)-binary, rest::binary()>>} + ) do + new_tzif = %{ + tzif + | version: version - ?0, + periods: + decode_transition_times( + 64, + transition_times, + transition_types, + local_time_types, + time_zone_designations, + standard_indicators, + ut_indicators + ) + } + + {new_tzif, rest} + end + + defp parse_v2_data(_other) do + {nil, nil} + end + + defp parse_footer({_tzif, <<>>} = token), do: token + + defp parse_footer({tzif, <>}) do + case String.split(footer, "\n", parts: 2) do + [tz_string, _rest] -> + {%{tzif | tz_string: tz_string}, <<>>} + + _other -> + # This is unexpected, so error out. + {nil, nil} + end + end + + defp parse_footer(_other) do + {nil, nil} + end + + defp decode_transition_times( + size, + transition_times, + transition_types, + local_time_types, + raw_tz_designations, + standard_indicators, + ut_indicators + ) do + times = for <>, do: to_gregorian_seconds(time) + + lt_record = + for <> do + {utoff, get_tz_abbr(raw_tz_designations, tz_index), std_or_dst(dst)} + end + + num_lt_records = length(lt_record) + std_wall = process_standard_indicators(standard_indicators, num_lt_records) + ut_local = process_ut_indicators(ut_indicators, num_lt_records) + + {first_utoff, first_abbr, _} = hd(lt_record) + prehistory_record = {-2_147_483_647, first_utoff, 0, first_abbr} + + lt_record_w_extra = Enum.zip([lt_record, std_wall, ut_local]) + types = for <>, do: Enum.at(lt_record_w_extra, type) + + process_records(times, types, 0, [prehistory_record]) + end + + defp process_records([], [], _st_offset, acc), do: acc + + defp process_records( + [time | times], + [{{utoff, tz_designation, dst}, _std_wall, _ut_local} | infos], + st_offset, + acc + ) do + new_st_offset = if dst == :std, do: utoff, else: st_offset + + record = {time, new_st_offset, utoff - new_st_offset, tz_designation} + # IO.puts("#{inspect(record)} #{inspect(dst)} #{std_wall} #{ut_local}") + process_records(times, infos, new_st_offset, [record | acc]) + end + + defp to_gregorian_seconds(unix_time) do + unix_time + 62_167_219_200 + end + + defp process_standard_indicators(<<>>, expected) do + List.duplicate(:wall_time, expected) + end + + defp process_standard_indicators(standard_indicators, _expected) do + for <>, do: to_stdwall(b) + end + + defp process_ut_indicators(<<>>, expected) do + List.duplicate(:local, expected) + end + + defp process_ut_indicators(ut_indicators, _expected) do + for <>, do: to_ut_local(b) + end + + defp std_or_dst(0), do: :std + defp std_or_dst(_), do: :dst + + defp to_stdwall(0), do: :wall_time + defp to_stdwall(_), do: :std_time + + defp to_ut_local(0), do: :local + defp to_ut_local(_), do: :ut + + defp get_tz_abbr(raw_tz_designations, index) do + raw_tz_designations + |> :binary.bin_to_list() + |> Enum.drop(index) + |> take_til_null() + |> to_string() + end + + defp take_til_null(string, acc \\ []) + + defp take_til_null([0 | _rest], acc) do + acc + |> Enum.reverse() + end + + defp take_til_null([c | rest], acc) do + take_til_null(rest, [c | acc]) + end +end diff --git a/lib/zoneinfo/utils.ex b/lib/zoneinfo/utils.ex new file mode 100644 index 0000000..2c4b838 --- /dev/null +++ b/lib/zoneinfo/utils.ex @@ -0,0 +1,14 @@ +defmodule Zoneinfo.Utils do + @moduledoc false + + @spec iso_days_to_gregorian_seconds(Calendar.iso_days()) :: integer() + def iso_days_to_gregorian_seconds({days, {parts_in_day, 86_400_000_000}}) do + div(days * 86_400_000_000 + parts_in_day, 1_000_000) + end + + @spec gregorian_seconds_to_naive_datetime(non_neg_integer()) :: NaiveDateTime.t() + def gregorian_seconds_to_naive_datetime(seconds) do + :calendar.gregorian_seconds_to_datetime(seconds) + |> NaiveDateTime.from_erl!() + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..4626460 --- /dev/null +++ b/mix.exs @@ -0,0 +1,80 @@ +defmodule Zoneinfo.MixProject do + use Mix.Project + + @version "0.1.0" + @source_url "https://github.com/smartrent/zoneinfo" + + def project do + [ + app: :zoneinfo, + version: "0.1.0", + elixir: "~> 1.11", + description: description(), + package: package(), + compilers: compilers(Mix.env()), + make_targets: ["all"], + make_clean: ["clean"], + start_permanent: Mix.env() == :prod, + deps: deps(), + dialyzer: [ + flags: [:unmatched_returns, :error_handling, :race_conditions, :underspecs] + ], + docs: docs(), + preferred_cli_env: %{ + docs: :docs, + "hex.publish": :docs, + "hex.build": :docs + } + ] + end + + def compilers(env) when env in [:dev, :test] do + [:elixir_make | Mix.compilers()] + end + + def compilers(_env), do: Mix.compilers() + + def application() do + [mod: {Zoneinfo.Application, []}] + end + + defp description do + "Elixir time zone support that uses OS-supplied zoneinfo files" + end + + defp package do + %{ + files: [ + "lib", + "mix.exs", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + licenses: ["Apache-2.0"], + links: %{"GitHub" => @source_url} + } + end + + defp deps do + [ + # No prod dependencies. These are only for dev and test. + {:dialyxir, "~> 1.0.0", only: :dev, runtime: false}, + {:ex_doc, "~> 0.22", only: :docs, runtime: false}, + {:elixir_make, "> 0.6.0", only: [:dev, :test]}, + # Locked dependencies to guarantee that tz and tzdata use the same IANA time zone database + # It's ok to update. Change the version in the Makefile. + {:tz, "~> 0.12.0", only: [:dev, :test]}, + {:tzdata, "~> 1.1.0", only: [:dev, :test]} + ] + end + + defp docs do + [ + extras: ["README.md", "CHANGELOG.md"], + main: "readme", + source_ref: "v#{@version}", + source_url: @source_url + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..ccc35d2 --- /dev/null +++ b/mix.lock @@ -0,0 +1,20 @@ +%{ + "certifi": {:hex, :certifi, "2.5.3", "70bdd7e7188c804f3a30ee0e7c99655bc35d8ac41c23e12325f36ab449b70651", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "ed516acb3929b101208a9d700062d520f3953da3b6b918d866106ffa980e1c10"}, + "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, + "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, + "hackney": {:hex, :hackney, "1.17.0", "717ea195fd2f898d9fe9f1ce0afcc2621a41ecfe137fae57e7fe6e9484b9aa99", [:rebar3], [{:certifi, "~>2.5", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "64c22225f1ea8855f584720c0e5b3cd14095703af1c9fbc845ba042811dc671c"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "tz": {:hex, :tz, "0.12.0", "48659c720f948eed467bc16be26d82100b8e6da4961de96d87c5ba7e88521521", [:mix], [{:castore, "~> 0.1.5", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "f1bf4cb5178e7be2eb5b7df2830ae3e1535141dc06f88b4909f1431db58edbf1"}, + "tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, +} diff --git a/test/fixture/Honolulu_v1 b/test/fixture/Honolulu_v1 new file mode 100644 index 0000000..7e65d2e Binary files /dev/null and b/test/fixture/Honolulu_v1 differ diff --git a/test/fixture/Honolulu_v1_bad_count b/test/fixture/Honolulu_v1_bad_count new file mode 100644 index 0000000..bd6a9da Binary files /dev/null and b/test/fixture/Honolulu_v1_bad_count differ diff --git a/test/fixture/Honolulu_v2 b/test/fixture/Honolulu_v2 new file mode 100644 index 0000000..cf6c8fa Binary files /dev/null and b/test/fixture/Honolulu_v2 differ diff --git a/test/fixture/Honolulu_v2_bad_count b/test/fixture/Honolulu_v2_bad_count new file mode 100644 index 0000000..a05043e Binary files /dev/null and b/test/fixture/Honolulu_v2_bad_count differ diff --git a/test/fixture/Honolulu_v2_bad_count2 b/test/fixture/Honolulu_v2_bad_count2 new file mode 100644 index 0000000..4936e96 Binary files /dev/null and b/test/fixture/Honolulu_v2_bad_count2 differ diff --git a/test/fixture/Honolulu_v2_bad_footer b/test/fixture/Honolulu_v2_bad_footer new file mode 100644 index 0000000..bd64968 Binary files /dev/null and b/test/fixture/Honolulu_v2_bad_footer differ diff --git a/test/fixture/Honolulu_v2_no_footer b/test/fixture/Honolulu_v2_no_footer new file mode 100644 index 0000000..f6a747e Binary files /dev/null and b/test/fixture/Honolulu_v2_no_footer differ diff --git a/test/fixture/bad_header b/test/fixture/bad_header new file mode 100644 index 0000000..7efa74f Binary files /dev/null and b/test/fixture/bad_header differ diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/test/zoneinfo/cache_test.exs b/test/zoneinfo/cache_test.exs new file mode 100644 index 0000000..d34df4f --- /dev/null +++ b/test/zoneinfo/cache_test.exs @@ -0,0 +1,24 @@ +defmodule Zoneinfo.CacheTest do + use ExUnit.Case, async: false + alias Zoneinfo.Cache + + test "looks up timezone that exists" do + assert {:ok, _} = Cache.get("America/New_York") + end + + test "returns error on unknown timezones" do + assert {:error, :enoent} == Cache.get("Mars/Jezero_Crater") + end + + test "garbage collection clears loaded data" do + {:ok, _} = Cache.get("America/New_York") + assert :ets.info(Zoneinfo.Cache, :size) > 0 + + Process.send(Zoneinfo.Cache, :gc, []) + + # Wait for the message + Process.sleep(50) + + assert :ets.info(Zoneinfo.Cache, :size) == 0 + end +end diff --git a/test/zoneinfo/time_zone_database_test.exs b/test/zoneinfo/time_zone_database_test.exs new file mode 100644 index 0000000..ffafced --- /dev/null +++ b/test/zoneinfo/time_zone_database_test.exs @@ -0,0 +1,203 @@ +defmodule Zoneinfo.TimeZoneDatabaseTest do + use ExUnit.Case, async: true + import Zoneinfo.Utils + + @truth Tz + + # Set these to the range of times that are important + # Make sure that the Makefile generates tzif files that include + # range. + @earliest_time ~N[1940-01-02 00:00:00] + @latest_time ~N[2038-01-01 00:00:00] + + defp step_size(time_zone) do + # Vary the step size deterministically per time zone to try to + # cover a few more boundary conditions + nominal_step_size = 7 * 60 * 60 * 24 + + nominal_step_size + :erlang.phash2(time_zone, div(nominal_step_size, 4)) - + div(nominal_step_size, 8) + end + + defp check_time_zone(time_zone, time, end_time, step_size) do + iso_days = + Calendar.ISO.naive_datetime_to_iso_days( + time.year, + time.month, + time.day, + time.hour, + time.minute, + time.second, + {0, 6} + ) + + next_time = NaiveDateTime.add(time, step_size) + + zoneinfo_result = + Zoneinfo.TimeZoneDatabase.time_zone_period_from_utc_iso_days(iso_days, time_zone) + + expected_result = + @truth.TimeZoneDatabase.time_zone_period_from_utc_iso_days(iso_days, time_zone) + + assert same_results?(zoneinfo_result, expected_result), """ + Assertion failed for #{time_zone} @ #{inspect(time)} + + iso_days=#{inspect(iso_days)} + gregorian_seconds=#{inspect(iso_days_to_gregorian_seconds(iso_days))} + + Zoneinfo returned #{inspect(zoneinfo_result)} + #{@truth |> to_string() |> String.trim_leading("Elixir.")} returned #{ + inspect(expected_result) + } + """ + + if NaiveDateTime.compare(next_time, end_time) == :lt do + check_time_zone(time_zone, next_time, end_time, step_size) + end + end + + for time_zone <- Zoneinfo.time_zones() do + test "zoneinfo consistent for #{time_zone} for utc iso days" do + check_time_zone( + unquote(time_zone), + @earliest_time, + @latest_time, + step_size(unquote(time_zone)) + ) + end + end + + defp same_period?( + %{std_offset: s, utc_offset: u, zone_abbr: z}, + %{std_offset: s, utc_offset: u, zone_abbr: z} + ), + do: true + + # TODO: Debug why this is different sometimes. + defp same_period?( + %{std_offset: tzf1, utc_offset: tzf2, zone_abbr: z}, + %{std_offset: tz1, utc_offset: tz2, zone_abbr: z} + ) + when tzf1 + tzf2 == tz1 + tz2, + do: true + + defp same_period?(_a, _b), do: false + + defp same_results?({:ok, p1}, {:ok, p2}) do + same_period?(p1, p2) + end + + defp same_results?({:gap, {ap1, t1}, {ap2, t2}}, {:gap, {bp1, t1}, {bp2, t2}}) do + same_period?(ap1, bp1) and same_period?(ap2, bp2) + end + + defp same_results?({:ambiguous, ap1, ap2}, {:ambiguous, bp1, bp2}) do + same_period?(ap1, bp1) and same_period?(ap2, bp2) + end + + defp same_results?(a, b), do: a == b + + defp check_wall_clock(time_zone, time, end_time, step_size) do + next_time = NaiveDateTime.add(time, step_size) + + zoneinfo_result = + Zoneinfo.TimeZoneDatabase.time_zone_periods_from_wall_datetime(time, time_zone) + + expected_result = + @truth.TimeZoneDatabase.time_zone_periods_from_wall_datetime(time, time_zone) + + assert same_results?(zoneinfo_result, expected_result), """ + Assertion failed for #{time_zone} @ #{inspect(time)} + + + Zoneinfo returned #{inspect(zoneinfo_result)} + #{@truth |> to_string() |> String.trim_leading("Elixir.")} returned #{ + inspect(expected_result) + } + """ + + if NaiveDateTime.compare(next_time, end_time) == :lt do + check_wall_clock(time_zone, next_time, end_time, step_size) + end + end + + for time_zone <- Zoneinfo.time_zones() do + test "zoneinfo consistent for #{time_zone} for wall clock inputs" do + check_wall_clock( + unquote(time_zone), + @earliest_time, + @latest_time, + step_size(unquote(time_zone)) + ) + end + end + + # test "time_zone period from utc iso days", %{time_zones: time_zones} do + # ndt_now = NaiveDateTime.local_now() + + # for time_zone <- time_zones do + # for delta_days <- Enum.take_every(0..10000, 30) do + # delta_seconds = delta_days * 24 * 60 * 60 * -1 + # ndt = NaiveDateTime.add(ndt_now, delta_seconds, :second) + + # iso_days = + # Calendar.ISO.naive_datetime_to_iso_days( + # ndt.year, + # ndt.month, + # ndt.day, + # ndt.hour, + # ndt.minute, + # ndt.second, + # {0, 6} + # ) + + # case Tz.TimeZoneDatabase.time_zone_period_from_utc_iso_days(iso_days, time_zone) do + # {:ok, + # %{ + # std_offset: std_offset, + # utc_offset: utc_offset, + # zone_abbr: abbr + # }} -> + # # assuming largest std offset i 1 hour + # assert abs(std_offset) in 0..(60 * 60) + # # largest utc offset is 14 hours + # assert abs(utc_offset) in 0..(14 * 60 * 60) + # assert is_binary(abbr) + + # {:error, :time_zone_not_found} -> + # IO.puts("Time zone not found #{time_zone}") + # assert false + # end + # end + # end + # end + + # test "time_zone period from wall date time", %{time_zones: time_zones} do + # ndt_now = NaiveDateTime.local_now() + + # for time_zone <- time_zones do + # for delta_days <- Enum.take_every(0..10000, 30) do + # delta_seconds = delta_days * 24 * 60 * 60 * -1 + # ndt = NaiveDateTime.add(ndt_now, delta_seconds, :second) + + # case Tz.TimeZoneDatabase.time_zone_periods_from_wall_datetime(ndt, time_zone) do + # {:ok, + # %{ + # std_offset: std_offset, + # utc_offset: utc_offset, + # zone_abbr: abbr + # }} -> + # # assuming largest std offset i 1 hour + # assert abs(std_offset) in 0..(60 * 60) + # # largest utc offset is 14 hours + # assert abs(utc_offset) in 0..(14 * 60 * 60) + # assert is_binary(abbr) + + # {:error, :time_zone_not_found} -> + # IO.puts("Time zone not found #{time_zone}") + # assert false + # end + # end + # end + # end +end diff --git a/test/zoneinfo/tzif_test.exs b/test/zoneinfo/tzif_test.exs new file mode 100644 index 0000000..dfb94ab --- /dev/null +++ b/test/zoneinfo/tzif_test.exs @@ -0,0 +1,56 @@ +defmodule Zoneinfo.TZifTest do + use ExUnit.Case, async: true + + alias Zoneinfo.TZif + + @fixture_path Path.join(__DIR__, "../fixture") + + defp parse_file(name) do + Path.join(@fixture_path, name) + |> File.read!() + |> TZif.parse() + end + + test "loads v1 files" do + {:ok, tzif} = parse_file("Honolulu_v1") + + assert tzif.version == 1 + assert length(tzif.periods) == 7 + end + + test "loads v2 files" do + {:ok, tzif} = parse_file("Honolulu_v2") + + assert tzif.version == 2 + assert length(tzif.periods) == 8 + assert tzif.tz_string == "HST10" + end + + test "is ok with missing v2 footer" do + {:ok, tzif} = parse_file("Honolulu_v2_no_footer") + + assert tzif.version == 2 + assert length(tzif.periods) == 8 + assert tzif.tz_string == nil + end + + test "rejects bad headers" do + assert {:error, :invalid} = parse_file("bad_header") + end + + test "rejects bad v1 count" do + assert {:error, :invalid} = parse_file("Honolulu_v1_bad_count") + end + + test "rejects bad v2 count" do + # The bad count is in the v1 section + assert {:error, :invalid} = parse_file("Honolulu_v2_bad_count") + + # The bad count is in the v2 section + assert {:error, :invalid} = parse_file("Honolulu_v2_bad_count2") + end + + test "rejects bad v2 footer" do + assert {:error, :invalid} = parse_file("Honolulu_v2_bad_footer") + end +end diff --git a/test/zoneinfo/utils_test.exs b/test/zoneinfo/utils_test.exs new file mode 100644 index 0000000..2447842 --- /dev/null +++ b/test/zoneinfo/utils_test.exs @@ -0,0 +1,34 @@ +defmodule Zoneinfo.UtilsTest do + use ExUnit.Case, async: true + alias Zoneinfo.Utils + + test "iso_days_to_gregorian_seconds/1" do + ndt = NaiveDateTime.local_now() + + iso_days = + Calendar.ISO.naive_datetime_to_iso_days( + ndt.year, + ndt.month, + ndt.day, + ndt.hour, + ndt.minute, + ndt.second, + ndt.microsecond + ) + + greg_seconds = Utils.iso_days_to_gregorian_seconds(iso_days) + + {expected_greg_seconds, _micros} = NaiveDateTime.to_gregorian_seconds(ndt) + + assert greg_seconds == expected_greg_seconds + end + + test "datetime to gregorian and back" do + ndt = NaiveDateTime.local_now() + + {secs, _micros} = NaiveDateTime.to_gregorian_seconds(ndt) + output = Utils.gregorian_seconds_to_naive_datetime(secs) + + assert ndt == output + end +end diff --git a/test/zoneinfo_test.exs b/test/zoneinfo_test.exs new file mode 100644 index 0000000..74f8281 --- /dev/null +++ b/test/zoneinfo_test.exs @@ -0,0 +1,58 @@ +defmodule ZoneinfoTest do + use ExUnit.Case, async: false + doctest Zoneinfo + + test "time_zones/0" do + all_time_zones = Zoneinfo.time_zones() + + # Spot check that the Makefile ran zic on all expected data files + assert "America/New_York" in all_time_zones + assert "America/Argentina/Buenos_Aires" in all_time_zones + assert "Africa/Cairo" in all_time_zones + assert "Australia/Sydney" in all_time_zones + assert "Antarctica/Troll" in all_time_zones + assert "Asia/Tokyo" in all_time_zones + assert "Europe/London" in all_time_zones + assert "Indian/Maldives" in all_time_zones + assert "Pacific/Tahiti" in all_time_zones + assert "Etc/UTC" in all_time_zones + + # Check that directories weren't included + refute "America" in all_time_zones + end + + describe "tzpath/0" do + test "app environment" do + # This is set in the config.exs for testing + assert Zoneinfo.tzpath() == Application.app_dir(:zoneinfo, ["priv", "zoneinfo"]) + end + + test "OS environment" do + old_path = clear_path() + System.put_env("TZPATH", "tzpath_environment") + + assert Zoneinfo.tzpath() == "tzpath_environment" + + :os.unsetenv('TZPATH') + pop_path(old_path) + end + + test "default" do + old_path = clear_path() + + assert Zoneinfo.tzpath() == "/usr/share/zoneinfo" + + pop_path(old_path) + end + + defp clear_path() do + old_path = Application.get_env(:zoneinfo, :tzpath) + Application.delete_env(:zoneinfo, :tzpath) + old_path + end + + defp pop_path(old_path) do + Application.put_env(:zoneinfo, :tzpath, old_path) + end + end +end