Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/TimeZones.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ using Dates
using Printf
using Scratch: @get_scratch!
using Unicode
using InlineStrings: InlineString15
using InlineStrings: InlineString15, InlineString31
using TZJData: TZJData

import Dates: TimeZone, UTC
Expand Down Expand Up @@ -42,7 +42,12 @@ abstract type Local <: TimeZone end

function __init__()
# Set at runtime to ensure relocatability
_COMPILED_DIR[] = @static if isdefined(TZJData, :artifact_dir)
# Prefer scratch-compiled directory with matching versions, fall back to artifact
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of the version feature flag and path hacks for testing both v1 and v2 behaviour.

expected_dir = TZData.compiled_dir()

_COMPILED_DIR[] = if isdir(expected_dir)
expected_dir
elseif isdefined(TZJData, :artifact_dir)
TZJData.artifact_dir()
else
# Backwards compatibility for TZJData versions below v1.3.1. The portion of the
Expand Down
24 changes: 20 additions & 4 deletions src/types/timezone.jl
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,27 @@ US/Pacific (UTC-8/UTC-7)
TimeZone(::AbstractString, ::Class)

function TimeZone(str::AbstractString, mask::Class=Class(:DEFAULT))
tz, class = get(_TZ_CACHE, str) do
tz, class, link = get(_TZ_CACHE, str) do
if occursin(FIXED_TIME_ZONE_REGEX, str)
FixedTimeZone(str), Class(:FIXED)
FixedTimeZone(str), Class(:FIXED), InlineString31("")
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, a Union with nothing is not isbits, hence the empty InlineString31.

else
throw(ArgumentError("Unknown time zone \"$str\""))
end
end

# Auto-redirect LEGACY timezones to their modern equivalents
# Only when user hasn't explicitly opted in to LEGACY class
if !isempty(link) && class == Class(:LEGACY) && mask & Class(:LEGACY) == Class(:NONE)
# Note: Using depwarn here allows users to control behavior via --depwarn flag.
# With --depwarn=error, this becomes an error (strict mode).
# This matches the behavior requested in issue #469 https://github.com/JuliaTime/TimeZones.jl/issues/469#issuecomment-2341741754.
Base.depwarn(
"The time zone \"$str\" is deprecated, using \"$link\" instead.",
:TimeZone
)
return TimeZone(String(link), mask)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: If the backward file had a recursive case of a -> b -> c, then this would still error. The header comments say that's been avoided due to other parsers not handling it.

end

if mask & class == Class(:NONE)
throw(ArgumentError(
"The time zone \"$str\" is of class `$(repr(class))` which is " *
Expand Down Expand Up @@ -83,7 +96,10 @@ function istimezone(str::AbstractString, mask::Class=Class(:DEFAULT))
return true
end

# Checks against pre-compiled time zones
class = get(() -> (UTC_ZERO, Class(:NONE)), _TZ_CACHE, str)[2]
# Checks against pre-compiled time zones (3-tuple now: tz, class, link)
_, class, link = get(() -> (UTC_ZERO, Class(:NONE), InlineString31("")), _TZ_CACHE, str)

# Allow linked legacy timezones to auto-redirect
!isempty(link) && class == Class(:LEGACY) && mask & Class(:LEGACY) == Class(:NONE) && return true
return mask & class != Class(:NONE)
end
31 changes: 22 additions & 9 deletions src/types/timezonecache.jl
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Use a separate cache for FixedTimeZone (which is `isbits`) so the container is concretely
# typed and we avoid allocating a FixedTimeZone every time we get one from the cache.
# Note: link uses InlineString31 (not Union{InlineString31,Nothing}) to keep tuples isbits.
# An empty string "" is used as a sentinel value for "no link target".
struct TimeZoneCache
ftz::Dict{String,Tuple{FixedTimeZone,Class}}
vtz::Dict{String,Tuple{VariableTimeZone,Class}}
ftz::Dict{String,Tuple{FixedTimeZone,Class,InlineString31}}
vtz::Dict{String,Tuple{VariableTimeZone,Class,InlineString31}}
lock::ReentrantLock
initialized::Threads.Atomic{Bool}
end
Expand All @@ -28,12 +30,16 @@ function reload!(cache::TimeZoneCache, compiled_dir::AbstractString=_COMPILED_DI
empty!(cache.vtz)

walk_tz_dir(compiled_dir) do name, path
tz, class = open(TZJFile.read, path, "r")(name)
tz, class, link = open(TZJFile.read, path, "r")(name)

# Convert link to InlineString31 to keep tuples isbits
# Use empty string as sentinel for "no link target"
entry = (tz, class, link === nothing ? InlineString31("") : InlineString31(link))

if tz isa FixedTimeZone
cache.ftz[name] = (tz, class)
cache.ftz[name] = entry
elseif tz isa VariableTimeZone
cache.vtz[name] = (tz, class)
cache.vtz[name] = entry
else
error("Unhandled TimeZone class encountered: $(typeof(tz))")
end
Expand Down Expand Up @@ -63,10 +69,17 @@ function Base.get(body::Function, cache::TimeZoneCache, name::AbstractString)
end

# Build specific tzdata version if specified by `JULIA_TZ_VERSION`
function _build()
desired_version = TZData.tzdata_version()
if desired_version != TZJData.TZDATA_VERSION
_COMPILED_DIR[] = TZData.build(desired_version, _scratch_dir())
# Also rebuilds if the TZJFile format version doesn't match the expected version
function _build(tzjf_version::Integer=TZJFile.tzjfile_version())
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another main hack is for switching compiled file formats.

expected_dir = TZData.compiled_dir()

# Rebuild if the expected directory doesn't exist
# TODO: I believe this currently avoids using TZJData.jl and forces a rebuild, but makes testing V1 vs V2 behaviour easier
if !isdir(expected_dir)
_COMPILED_DIR[] = TZData.build(TZData.tzdata_version(), _scratch_dir(); tzjf_version)
elseif _COMPILED_DIR[] != expected_dir
# Expected directory exists but we're pointing to wrong location (e.g., artifact)
_COMPILED_DIR[] = expected_dir
end

return nothing
Expand Down
23 changes: 18 additions & 5 deletions src/tzdata/build.jl
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,25 @@ const REGIONS = [STANDARD_REGIONS; LEGACY_REGIONS]

_archive_relative_dir() = "archive"
_tz_source_relative_dir(version::AbstractString) = joinpath("tzsource", version)
_compiled_relative_dir(version::AbstractString) = joinpath("compiled", "tzjf", "v$(TZJFile.DEFAULT_VERSION)", version)
_compiled_relative_dir(version::AbstractString, tzjf_version::Integer) = joinpath("compiled", "tzjf", "v$tzjf_version", version)

function build(version::AbstractString, working_dir::AbstractString)
"""
compiled_dir() -> String

Returns the expected compiled directory path for the current tzdata and TZJFile versions.
This is where TimeZones.jl will look for compiled timezone data.
"""
function compiled_dir()
return joinpath(
_scratch_dir(),
_compiled_relative_dir(tzdata_version(), TZJFile.tzjfile_version())
)
end

function build(version::AbstractString, working_dir::AbstractString; tzjf_version::Integer=TZJFile.tzjfile_version())
tzdata_archive_dir = joinpath(working_dir, _archive_relative_dir())
tz_source_dir = joinpath(working_dir, _tz_source_relative_dir(version))
compiled_dir = joinpath(working_dir, _compiled_relative_dir(version))
compiled_dir = joinpath(working_dir, _compiled_relative_dir(version, tzjf_version))

url = tzdata_url(version)
tzdata_archive_file = joinpath(tzdata_archive_dir, basename(url))
Expand Down Expand Up @@ -63,10 +76,10 @@ function build(version::AbstractString, working_dir::AbstractString)
return compiled_dir
end

function cleanup(version::AbstractString, working_dir::AbstractString)
function cleanup(version::AbstractString, working_dir::AbstractString; tzjf_version::Integer=TZJFile.tzjfile_version())
tzdata_archive_file = joinpath(working_dir, _archive_relative_dir(), basename(tzdata_url(version)))
tz_source_dir = joinpath(working_dir, _tz_source_relative_dir(version))
compiled_dir = joinpath(working_dir, _compiled_relative_dir(version))
compiled_dir = joinpath(working_dir, _compiled_relative_dir(version, tzjf_version))

isfile(tzdata_archive_file) && rm(tzdata_archive_file)
isdir(tz_source_dir) && rm(tz_source_dir; recursive=true)
Expand Down
21 changes: 14 additions & 7 deletions src/tzdata/compile.jl
Original file line number Diff line number Diff line change
Expand Up @@ -643,32 +643,37 @@ end
function compile(name::AbstractString, tz_source::TZSource; kwargs...)
ordered = OrderedRuleDict()

# Get the direct link target if this is a link (no chain resolution needed)
# Note: We don't need to handle link chains (A→B→C) because the IANA tzdata
# backward file explicitly avoids them, as stated in the backward file header.
link = get(tz_source.links, name, nothing)

if haskey(tz_source.links, name)
# When the name is a link we'll generate a time zone from the link's target and
# rename the time zone with the link name.
zone_name = tz_source.links[name]
tz = compile!(zone_name, tz_source, ordered; kwargs...)
class = Class(name, associated_regions(tz_source, name))

return rename(tz, name), class
return rename(tz, name), class, link
else
tz = compile!(name, tz_source, ordered; kwargs...)
class = Class(name, associated_regions(tz_source, name))

return tz, class
return tz, class, link
end
end

function compile(tz_source::TZSource; kwargs...)
results = Vector{Tuple{TimeZone,Class}}()
results = Vector{Tuple{TimeZone,Class,Union{String,Nothing}}}()
ordered = OrderedRuleDict()
lookup = Dict{String,TimeZone}()

for zone_name in keys(tz_source.zones)
tz = compile!(zone_name, tz_source, ordered; kwargs...)
class = Class(zone_name, associated_regions(tz_source, zone_name))

push!(results, (tz, class))
push!(results, (tz, class, nothing))
lookup[zone_name] = tz
end

Expand All @@ -678,8 +683,10 @@ function compile(tz_source::TZSource; kwargs...)
target_tz = lookup[target]
tz = rename(target_tz, link_name)
class = Class(link_name, associated_regions(tz_source, link_name))
# Only store link target for LEGACY timezones to save memory
link = class == Class(:LEGACY) ? target : nothing

push!(results, (tz, class))
push!(results, (tz, class, link))
elseif !haskey(lookup, target)
error("Unable to resolve link \"$link_name\" referencing \"$target\"")
end
Expand All @@ -692,15 +699,15 @@ function compile(tz_source::TZSource, dest_dir::AbstractString; kwargs...)
results = compile(tz_source; kwargs...)
isdir(dest_dir) || error("Destination directory doesn't exist")

for (tz, class) in results
for (tz, class, link) in results
parts = split(TimeZones.name(tz), '/')
tz_path = joinpath(dest_dir, parts...)
tz_dir = dirname(tz_path)

isdir(tz_dir) || mkpath(tz_dir)

open(tz_path, "w") do fp
TZJFile.write(fp, tz; class)
TZJFile.write(fp, tz; class, link)
end
end

Expand Down
31 changes: 30 additions & 1 deletion src/tzjfile/TZJFile.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,36 @@ using Dates: Dates, DateTime, Second, datetime2unix, unix2datetime
using ...TimeZones: FixedTimeZone, VariableTimeZone, Class, Transition
using ...TimeZones.TZFile: combine_designations, get_designation, timestamp_min

const DEFAULT_VERSION = 1
const DEFAULT_VERSION = 2

"""
tzjfile_version() -> Int

Returns the TZJFile format version to use, controlled by the `JULIA_TZJ_VERSION` environment
variable. If not set, defaults to `DEFAULT_VERSION` (currently $DEFAULT_VERSION).

This allows users to opt-in or opt-out of new file format versions for testing or
compatibility purposes.

# Examples
```julia
# Use default version (currently 2)
julia> TZJFile.tzjfile_version()
2

# Use version 1 via environment variable
julia> ENV["JULIA_TZJ_VERSION"] = "1"
julia> TZJFile.tzjfile_version()
1
```
"""
function tzjfile_version()
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, a handy feature for switching compiled file versions, but it could be removed.

version_str = get(ENV, "JULIA_TZJ_VERSION", string(DEFAULT_VERSION))
version = tryparse(Int, version_str)
version === nothing && error("Invalid JULIA_TZJ_VERSION: \"$version_str\". Must be an integer (1 or 2).")
version ∉ (1, 2) && error("Unsupported JULIA_TZJ_VERSION: $version. Must be 1 or 2.")
return version
end

include("utils.jl")
include("read.jl")
Expand Down
28 changes: 26 additions & 2 deletions src/tzjfile/read.jl
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function read_content(io::IO, version::Val{1})
# Now build the time zone transitions
tz_constructor = if tzh_timecnt == 0 || (tzh_timecnt == 1 && transition_types[1] == TIMESTAMP_MIN)
tzj_info = transition_types[1]
name -> (FixedTimeZone(name, tzj_info.utc_offset, tzj_info.dst_offset), class)
name -> (FixedTimeZone(name, tzj_info.utc_offset, tzj_info.dst_offset), class, nothing)
else
transitions = Transition[]
cutoff = timestamp2datetime(cutoff_time, nothing)
Expand All @@ -75,8 +75,32 @@ function read_content(io::IO, version::Val{1})
prev_zone = zone
end

name -> (VariableTimeZone(name, transitions, cutoff), class)
name -> (VariableTimeZone(name, transitions, cutoff), class, nothing)
end

return tz_constructor
end

function read_content(io::IO, version::Val{2})
# Read v1 content first (reuse existing implementation)
tz_constructor_v1 = read_content(io, Val(1))

# Read version 2 extension: link information
has_link = ntoh(Base.read(io, UInt8)) != 0
link = if has_link
length = ntoh(Base.read(io, UInt16))
chars = Vector{UInt8}(undef, length)
for i in eachindex(chars)
chars[i] = ntoh(Base.read(io, UInt8))
end
String(chars)
else
nothing
end

# Return constructor that adds link to v1 result
return function(name)
tz, class, _ = tz_constructor_v1(name)
return (tz, class, link)
end
end
42 changes: 40 additions & 2 deletions src/tzjfile/write.jl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function write(io::IO, tz::VariableTimeZone; class::Class, version::Integer=DEFAULT_VERSION)
function write(io::IO, tz::VariableTimeZone; class::Class, version::Integer=tzjfile_version(), link::Union{String,Nothing}=nothing)
combined_designation, designation_indices = combine_designations(t.zone.name for t in tz.transitions)

# TODO: Sorting provides us a way to avoid checking for the sentinel on each loop
Expand All @@ -25,10 +25,11 @@ function write(io::IO, tz::VariableTimeZone; class::Class, version::Integer=DEFA
transition_types,
cutoff,
combined_designation,
link,
)
end

function write(io::IO, tz::FixedTimeZone; class::Class, version::Integer=DEFAULT_VERSION)
function write(io::IO, tz::FixedTimeZone; class::Class, version::Integer=tzjfile_version(), link::Union{String,Nothing}=nothing)
combined_designation, designation_indices = combine_designations([tz.name])

transition_times = Vector{Int64}()
Expand All @@ -53,6 +54,7 @@ function write(io::IO, tz::FixedTimeZone; class::Class, version::Integer=DEFAULT
transition_types,
cutoff,
combined_designation,
link,
)
end

Expand All @@ -71,6 +73,7 @@ function write_content(
transition_types::Vector{TZJTransition},
cutoff::Int64,
combined_designation::AbstractString,
link::Union{String,Nothing}=nothing, # Ignored in v1 for compatibility
)
if length(transition_times) > 0
unique_transition_types = unique(transition_types)
Expand Down Expand Up @@ -111,3 +114,38 @@ function write_content(

return nothing
end

function write_content(
io::IO,
version::Val{2};
class::UInt8,
transition_times::Vector{Int64},
transition_types::Vector{TZJTransition},
cutoff::Int64,
combined_designation::AbstractString,
link::Union{String,Nothing}=nothing,
)
# Write v1 content first (reuse existing implementation)
write_content(
io,
Val(1);
class,
transition_times,
transition_types,
cutoff,
combined_designation,
)

# Version 2 extension: write link information
if link === nothing
Base.write(io, hton(UInt8(0))) # No link target
else
Base.write(io, hton(UInt8(1))) # Has link target
Base.write(io, hton(UInt16(length(link))))
for char in link
Base.write(io, hton(UInt8(char)))
end
end

return nothing
end
Loading
Loading