Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ZonedDateTime can parse its own string representation #483

Merged
merged 16 commits into from
Feb 3, 2025
Merged
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
12 changes: 11 additions & 1 deletion src/deprecated.jl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Base: @deprecate
using Base: @deprecate, depwarn

# BEGIN TimeZones 1.0 deprecations

Expand All @@ -13,4 +13,14 @@ const TransitionTimeInfo = TZFile.TransitionTimeInfo

@deprecate build(; force=false) build(TZJData.TZDATA_VERSION; force)

function Dates.default_format(::Type{ZonedDateTime})
depwarn(
"`Dates.default_format(ZonedDateTime)` is deprecated and has no direct " *
"replacement. Consider using refactoring to use " *
"`parse(::Type{ZonedDateTime}, ::AbstractString)` as an alternative.",
:default_format,
)
return ISOZonedDateTimeFormat
end

# END TimeZones 1.0 deprecations
64 changes: 42 additions & 22 deletions src/parse.jl
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,51 @@
)
end

const ISOZonedDateTimeFormat = let
begin
# Needs to be initialized to construct formats
init_dates_extension()
DateFormat("yyyy-mm-ddTHH:MM:SS.ssszzz")

# Follows the ISO 8601 standard for date and time with an offset. See
# `Dates.ISODateTimeFormat` for the `DateTime` equivalent.
const ISOZonedDateTimeFormat = DateFormat("yyyy-mm-dd\\THH:MM:SS.ssszzz")
const ISOZonedDateTimeNoMillisecondFormat = DateFormat("yyyy-mm-dd\\THH:MM:SSzzz")
end

Dates.default_format(::Type{ZonedDateTime}) = ISOZonedDateTimeFormat
Copy link
Member

Choose a reason for hiding this comment

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

As you said this isn't documented anywhere so it's technically not public. I think it's safest to deprecate this in the off chance anyone is using this.

@doc """
DateFormat(format::AbstractString, locale="english") --> DateFormat

When the `TimeZones` package is loaded, 2 extra character codes are available
for constructing the `format` string:

| Code | Matches | Comment |
|:-----------|:-------------------|:------------------------------------------------------|
| `z` | +02:00, -0100, +14 | Parsing matches a fixed numeric UTC offset `±hh:mm`, `±hhmm`, or `±hh`. Formatting outputs `±hh:mm` |
| `Z` | UTC, GMT, America/New_York | Name of a time zone as specified in the IANA tz database |
""" DateFormat

function Base.parse(::Type{ZonedDateTime}, str::AbstractString)
# Works as the format should only contain a period when milliseconds are included
return if contains(str, '.')
parse(ZonedDateTime, str, ISOZonedDateTimeFormat)
else
parse(ZonedDateTime, str, ISOZonedDateTimeNoMillisecondFormat)
end
end

function Base.parse(::Type{ZonedDateTime}, str::AbstractString, df::DateFormat)
argtypes = Tuple{Type{<:TimeType},AbstractString,DateFormat}
try
invoke(parse, argtypes, ZonedDateTime, str, df)
catch e
if e isa ArgumentError
rethrow(ArgumentError(
"Unable to parse string \"$str\" using format $df. $(e.msg)"
))
else
rethrow()

Check warning on line 54 in src/parse.jl

View check run for this annotation

Codecov / codecov/patch

src/parse.jl#L54

Added line #L54 was not covered by tests
end
end
end

function tryparsenext_fixedtz(str, i, len, min_width::Int=1, max_width::Int=0)
i == len && str[i] === 'Z' && return ("Z", i+1)
Expand Down Expand Up @@ -97,25 +136,6 @@
write(io, string(zdt.zone)) # In most cases will be an abbreviation.
end

function ZonedDateTime(str::AbstractString, df::DateFormat=ISOZonedDateTimeFormat)
try
parse(ZonedDateTime, str, df)
catch e
if e isa ArgumentError
rethrow(ArgumentError(
"Unable to parse string \"$str\" using format $df. $(e.msg)"
))
else
rethrow()
end
end
end

function ZonedDateTime(str::AbstractString, format::AbstractString; locale::AbstractString="english")
ZonedDateTime(str, DateFormat(format, locale))
end


"""
_parsesub_tzabbr(str, [i, len]) -> Union{Tuple{AbstractString, Integer}, Exception}

Expand Down
45 changes: 45 additions & 0 deletions src/types/zoneddatetime.jl
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,51 @@ function ZonedDateTime(date::Date, args...; kwargs...)
return ZonedDateTime(DateTime(date), args...; kwargs...)
end

# Parsing constructors

"""
ZonedDateTime(str::AbstractString)

Construct a `ZonedDateTime` by parsing `str`. This method is designed so that
`zdt == ZonedDateTime(string(zdt))` where `zdt` can be any `ZonedDateTime`
object. Take note that this method will always create a `ZonedDateTime` with a
`FixedTimeZone` which can result in different results with date/time arithmetic.

## Examples
```jltest
julia> zdt = ZonedDateTime(2025, 3, 8, 9, tz"America/New_York")
2025-03-08T09:00:00-05:00

julia> timezone(zdt)
America/New_York (UTC-5/UTC-4)

julia> zdt + Day(1)
2025-03-09T09:00:00-04:00

julia> pzdt = ZonedDateTime(string(zdt))
2025-03-08T09:00:00-05:00

julia> timezone(pzdt)
UTC-05:00

julia> pzdt + Day(1)
2025-03-09T09:00:00-05:00
```
"""
ZonedDateTime(str::AbstractString) = parse(ZonedDateTime, str)

"""
ZonedDateTime(str::AbstractString, df::DateFormat)

Construct a `ZonedDateTime` by parsing `str` according to the format specified
in `df`.
"""
ZonedDateTime(str::AbstractString, df::DateFormat) = parse(ZonedDateTime, str, df)

function ZonedDateTime(str::AbstractString, format::AbstractString; locale::AbstractString="english")
return parse(ZonedDateTime, str, DateFormat(format, locale))
end

# Promotion

# Because of the promoting fallback definitions for TimeType, we need a special case for
Expand Down
3 changes: 3 additions & 0 deletions test/deprecated.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@testset "default format" begin
@test Dates.default_format(ZonedDateTime) === TimeZones.ISOZonedDateTimeFormat
end
42 changes: 34 additions & 8 deletions test/parse.jl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Dates: parse_components, default_format
using Dates: parse_components
using TimeZones: ParseNextError, _parsesub_tzabbr, _parsesub_offset, _parsesub_time, _parsesub_tzdate, _parsesub_tz

@testset "parse" begin
Expand All @@ -21,7 +21,6 @@ using TimeZones: ParseNextError, _parsesub_tzabbr, _parsesub_offset, _parsesub_t
parse(ZonedDateTime, Test.GenericString("2018-01-01 00:00 UTC"), dateformat"yyyy-mm-dd HH:MM ZZZ"),
ZonedDateTime(2018, 1, 1, 0, tz"UTC"),
)

end

@testset "tryparse" begin
Expand Down Expand Up @@ -49,10 +48,6 @@ end
@test parse_components(test...) == expected
end

@testset "default format" begin
@test default_format(ZonedDateTime) === TimeZones.ISOZonedDateTimeFormat
end

@testset "parse constructor" begin
@test isequal(
ZonedDateTime("2000-01-02T03:04:05.006+0700"),
Expand All @@ -66,15 +61,46 @@ end
ZonedDateTime("2018-11-01-0600", dateformat"yyyy-mm-ddzzzz"),
ZonedDateTime(2018, 11, 1, tz"UTC-06"),
)
end

@testset "self parseable" begin
zdt_args = Iterators.product(
[0, 1, 10, 100, 1000, 2025, 10000], # Year
[1, 12], # Month
[3, 31], # Day
[0, 4, 23], # Hour
[0, 5, 55], # Minute
[0, 6, 56], # Seconds
[0, 7, 50, 77, 777], # Milliseconds
[tz"UTC-06", tz"UTC", tz"UTC+08:45", tz"UTC+14"], # Time zones
)
for args in zdt_args
zdt = ZonedDateTime(args...)
@test zdt == parse(ZonedDateTime, string(zdt))
@test zdt == ZonedDateTime(string(zdt))
end
end

# Validate that error message contains the original string and the format used
# Validate that error message contains the original string and the format used
@testset "contextual error" begin
str = "2018-11-01"

try
parse(ZonedDateTime, str)
@test false
catch e
@test e isa ArgumentError
@test occursin(str, e.msg)
@test occursin(string(TimeZones.ISOZonedDateTimeNoMillisecondFormat), e.msg)
end

try
ZonedDateTime(str)
@test false
catch e
@test e isa ArgumentError
@test occursin(str, e.msg)
@test occursin(string(TimeZones.ISOZonedDateTimeFormat), e.msg)
@test occursin(string(TimeZones.ISOZonedDateTimeNoMillisecondFormat), e.msg)
end
end

Expand Down
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,5 @@ include("helpers.jl")
include("rounding.jl")
include("parse.jl")
include("plotting.jl")
include("deprecated.jl")
end
Loading