From 1e1af768e232dc2ba406873b775cc0fe11fa28b0 Mon Sep 17 00:00:00 2001 From: Curtis Vogt Date: Tue, 21 May 2024 14:54:49 -0500 Subject: [PATCH] Fix fixed-length negative year parsing (#54535) Follow up to #53981. Fixes an issue introduced with negative years and fixed-with date formats: ```julia julia> Dates.DateTime("-20240521", "yyyymmdd") ERROR: ArgumentError: Month: 40 out of range (1:12) Stacktrace: [1] DateTime(y::Int64, m::Int64, d::Int64, h::Int64, mi::Int64, s::Int64, ms::Int64, ampm::Dates.AMPM) @ Dates ~/Development/Julia/aarch64/latest/usr/share/julia/stdlib/v1.12/Dates/src/types.jl:246 [2] parse(::Type{DateTime}, str::String, df::DateFormat{:yyyymmdd, Tuple{Dates.DatePart{'y'}, Dates.DatePart{'m'}, Dates.DatePart{'d'}}}) @ Dates ~/Development/Julia/aarch64/latest/usr/share/julia/stdlib/v1.12/Dates/src/parse.jl:294 [3] DateTime(dt::String, format::String; locale::Dates.DateLocale) @ Dates ~/Development/Julia/aarch64/latest/usr/share/julia/stdlib/v1.12/Dates/src/io.jl:555 [4] DateTime(dt::String, format::String) @ Dates ~/Development/Julia/aarch64/latest/usr/share/julia/stdlib/v1.12/Dates/src/io.jl:554 [5] top-level scope @ REPL[4]:1 ``` This PR makes it so that fixed-width formats require the specified number of digits. I also decided to only add the sign parsing for years to running into performance issues with parsing sign information where it isn't expected. --- stdlib/Dates/src/io.jl | 20 +++++++++++++++++++- stdlib/Dates/src/parse.jl | 31 ++++++++++++++++++++++--------- stdlib/Dates/test/io.jl | 28 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/stdlib/Dates/src/io.jl b/stdlib/Dates/src/io.jl index 3980ad3a7245f..388edb693d76f 100644 --- a/stdlib/Dates/src/io.jl +++ b/stdlib/Dates/src/io.jl @@ -111,7 +111,25 @@ end ### Parse tokens -for c in "yYmdHIMS" +for c in "yY" + @eval begin + @inline function tryparsenext(d::DatePart{$c}, str, i, len) + val = tryparsenext_sign(str, i, len) + if val !== nothing + coefficient, i = val + else + coefficient = 1 + end + # The sign character does not affect fixed length `DatePart`s + val = tryparsenext_base10(str, i, len, min_width(d), max_width(d)) + val === nothing && return nothing + y, ii = val + return y * coefficient, ii + end + end +end + +for c in "mdHIMS" @eval begin @inline function tryparsenext(d::DatePart{$c}, str, i, len) return tryparsenext_base10(str, i, len, min_width(d), max_width(d)) diff --git a/stdlib/Dates/src/parse.jl b/stdlib/Dates/src/parse.jl index b89401f086c38..e8624cf9243c5 100644 --- a/stdlib/Dates/src/parse.jl +++ b/stdlib/Dates/src/parse.jl @@ -156,18 +156,23 @@ If successful, returns a 2-element tuple `(values, pos)`: end end +@inline function tryparsenext_sign(str::AbstractString, i::Int, len::Int) + i > len && return nothing + c, ii = iterate(str, i)::Tuple{Char, Int} + if c == '+' + return 1, ii + elseif c == '-' + return -1, ii + else + return nothing + end +end + @inline function tryparsenext_base10(str::AbstractString, i::Int, len::Int, min_width::Int=1, max_width::Int=0) i > len && return nothing min_pos = min_width <= 0 ? i : i + min_width - 1 max_pos = max_width <= 0 ? len : min(i + max_width - 1, len) d::Int64 = 0 - c, neg = iterate(str, i)::Tuple{Char, Int} - if c == '-' - i = neg - neg = -1 - else - neg = 1 - end @inbounds while i <= max_pos c, ii = iterate(str, i)::Tuple{Char, Int} if '0' <= c <= '9' @@ -180,7 +185,7 @@ end if i <= min_pos return nothing else - return d * neg, i + return d, i end end @@ -207,10 +212,18 @@ function Base.parse(::Type{DateTime}, s::AbstractString, df::typeof(ISODateTimeF i, end_pos = firstindex(s), lastindex(s) i > end_pos && throw(ArgumentError("Cannot parse an empty string as a DateTime")) + coefficient = 1 local dy dm = dd = Int64(1) th = tm = ts = tms = Int64(0) + # Optional sign + let val = tryparsenext_sign(s, i, end_pos) + if val !== nothing + coefficient, i = val + end + end + let val = tryparsenext_base10(s, i, end_pos, 1) val === nothing && @goto error dy, i = val @@ -279,7 +292,7 @@ function Base.parse(::Type{DateTime}, s::AbstractString, df::typeof(ISODateTimeF end @label done - return DateTime(dy, dm, dd, th, tm, ts, tms) + return DateTime(dy * coefficient, dm, dd, th, tm, ts, tms) @label error throw(ArgumentError("Invalid DateTime string")) diff --git a/stdlib/Dates/test/io.jl b/stdlib/Dates/test/io.jl index 1a92ccac79c89..98bc610784477 100644 --- a/stdlib/Dates/test/io.jl +++ b/stdlib/Dates/test/io.jl @@ -325,6 +325,23 @@ end # From Matt Bauman f = "yyyy-mm-ddTHH:MM:SS" @test Dates.DateTime("2014-05-28T16:46:04", f) == Dates.DateTime(2014, 5, 28, 16, 46, 04) + + f = "yyyymmdd" + @test Dates.DateTime("20240521", f) == Dates.DateTime(2024, 5, 21) + @test Dates.DateTime("-20240521", f) == Dates.DateTime(-2024, 5, 21) + @test Dates.DateTime("+20240521", f) == Dates.DateTime(2024, 5, 21) + f = "YYYYmmdd" + @test Dates.DateTime("20240521", f) == Dates.DateTime(2024, 5, 21) + @test Dates.DateTime("-20240521", f) == Dates.DateTime(-2024, 5, 21) + @test Dates.DateTime("+20240521", f) == Dates.DateTime(2024, 5, 21) + f = "-yyyymmdd" + @test Dates.DateTime("-20240521", f) == Dates.DateTime(2024, 5, 21) + @test_throws ArgumentError Dates.DateTime("+20240521", f) + @test_throws ArgumentError Dates.DateTime("20240521", f) + f = "-YYYYmmdd" + @test Dates.DateTime("-20240521", f) == Dates.DateTime(2024, 5, 21) + @test_throws ArgumentError Dates.DateTime("+20240521", f) + @test_throws ArgumentError Dates.DateTime("20240521", f) end @testset "Error handling" begin @@ -403,6 +420,17 @@ end @test_throws ArgumentError parse(Date, "Foo, 12 Nov 2016 07:45:36", Dates.RFC1123Format) end +@testset "ISODateTimeFormat" begin + dt = Dates.DateTime(2024, 5, 21, 10, 57, 22) + neg_dt = Dates.DateTime(-2024, 5, 21, 10, 57, 22) + @test parse(Dates.DateTime, "2024-05-21T10:57:22", Dates.ISODateTimeFormat) == dt + @test parse(Dates.DateTime, "+2024-05-21T10:57:22", Dates.ISODateTimeFormat) == dt + @test parse(Dates.DateTime, "-2024-05-21T10:57:22", Dates.ISODateTimeFormat) == neg_dt + + @test_throws ArgumentError parse(Dates.DateTime, "-", Dates.ISODateTimeFormat) + @test_throws ArgumentError parse(Dates.DateTime, "+", Dates.ISODateTimeFormat) +end + @testset "Issue 15195" begin f = "YY" @test Dates.format(Dates.Date(1999), f) == "1999"