diff --git a/docs/src/conversion-to-julia.md b/docs/src/conversion-to-julia.md index 49539d4a..67d1e904 100644 --- a/docs/src/conversion-to-julia.md +++ b/docs/src/conversion-to-julia.md @@ -31,6 +31,8 @@ From Python, the arguments to a Julia function will be converted according to th | `datetime.date`/`datetime.time`/`datetime.datetime` | `Date`/`Time`/`DateTime` | | `datetime.timedelta` | `Microsecond` (or `Millisecond` or `Second` on overflow) | | `numpy.intXX`/`numpy.uintXX`/`numpy.floatXX` | `IntXX`/`UIntXX`/`FloatXX` | +| `numpy.datetime64` | `NumpyDates.DateTime64` | +| `numpy.timedelta64` | `NumpyDates.TimeDelta64` | | **Standard priority (other reasonable conversions).** | | | `None` | `Missing` | | `bytes` | `Vector{UInt8}`, `Vector{Int8}`, `String` | @@ -48,6 +50,8 @@ From Python, the arguments to a Julia function will be converted according to th | `ctypes.c_char_p` | `Cstring`, `Ptr{Cchar}`, `Ptr` | | `ctypes.c_wchar_p` | `Cwstring`, `Ptr{Cwchar}`, `Ptr` | | `numpy.bool_`/`numpy.intXX`/`numpy.uintXX`/`numpy.floatXX` | `Bool`, `Integer`, `Rational`, `Real`, `Number` | +| `numpy.datetime64` | `NumpyDates.InlineDateTime64`, `Dates.DateTime` | +| `numpy.timedelta64` | `NumpyDates.InlineTimeDelta64`, `Dates.Period` | | Objects satisfying the buffer or array interface | `Array`, `AbstractArray` | | **Low priority (fallback to `Py`).** | | | Anything | `Py` | diff --git a/docs/src/pythoncall-reference.md b/docs/src/pythoncall-reference.md index b1dd795e..f09d5f12 100644 --- a/docs/src/pythoncall-reference.md +++ b/docs/src/pythoncall-reference.md @@ -255,3 +255,18 @@ PythonCall.getptr PythonCall.pydel! PythonCall.unsafe_pynext ``` + +## NumpyDates + +The submodule `PythonCall.NumpyDates` provides types corresponding to Numpy's `datetime64` and `timedelta64` types. Enables conversion of these Numpy types (either as scalars or in arrays) to native Julia types. + +```@docs +PythonCall.NumpyDates +PythonCall.NumpyDates.AbstractDateTime64 +PythonCall.NumpyDates.InlineDateTime64 +PythonCall.NumpyDates.DateTime64 +PythonCall.NumpyDates.AbstractTimeDelta64 +PythonCall.NumpyDates.InlineTimeDelta64 +PythonCall.NumpyDates.TimeDelta64 +PythonCall.NumpyDates.Unit +``` diff --git a/docs/src/releasenotes.md b/docs/src/releasenotes.md index 05caf14f..7a9f1b65 100644 --- a/docs/src/releasenotes.md +++ b/docs/src/releasenotes.md @@ -1,6 +1,10 @@ # Release Notes ## Unreleased +* Added `NumpyDates`: NumPy-compatible DateTime64/TimeDelta64 types and units. +* Added `pyconvert` rules for NumpyDates types. +* Added `PyArray` support for NumPy arrays of `datetime64` and `timedelta64`. +* Added `juliacall.ArrayValue` support for Julia arrays of `InlineDateTime64` and `InlineTimeDelta64`. * Bug fixes. * Internal: switch from Requires.jl to package extensions. diff --git a/src/Convert/Convert.jl b/src/Convert/Convert.jl index 527c4c96..fc506fc6 100644 --- a/src/Convert/Convert.jl +++ b/src/Convert/Convert.jl @@ -9,6 +9,7 @@ using ..PythonCall using ..Utils using ..C using ..Core +using ..NumpyDates using Dates: Date, Time, DateTime, Second, Millisecond, Microsecond, Nanosecond @@ -20,8 +21,7 @@ import ..PythonCall: pyconvert, PyConvertPriority -export - pyconvert_isunconverted, +export pyconvert_isunconverted, pyconvert_result, pyconvert_result, pyconvert_tryconvert, diff --git a/src/Convert/numpy.jl b/src/Convert/numpy.jl index bd708e8a..73383d54 100644 --- a/src/Convert/numpy.jl +++ b/src/Convert/numpy.jl @@ -9,6 +9,76 @@ function (::pyconvert_rule_numpysimplevalue{R,SAFE})(::Type{T}, x::Py) where {R, end end +function pyconvert_rule_datetime64(::Type{DateTime64}, x::Py) + pyconvert_return(C.PySimpleObject_GetValue(DateTime64, x)) +end + +function pyconvert_rule_datetime64(::Type{T}, x::Py) where {T<:InlineDateTime64} + pyconvert_tryconvert(T, C.PySimpleObject_GetValue(DateTime64, x)) +end + +function pyconvert_rule_datetime64(::Type{T}, x::Py) where {T<:NumpyDates.DatesInstant} + d = C.PySimpleObject_GetValue(DateTime64, x) + if isnan(d) + pyconvert_unconverted() + else + pyconvert_tryconvert(T, d) + end +end + +function pyconvert_rule_datetime64(::Type{Missing}, x::Py) + d = C.PySimpleObject_GetValue(DateTime64, x) + if isnan(d) + pyconvert_return(missing) + else + pyconvert_unconverted() + end +end + +function pyconvert_rule_datetime64(::Type{Nothing}, x::Py) + d = C.PySimpleObject_GetValue(DateTime64, x) + if isnan(d) + pyconvert_return(nothing) + else + pyconvert_unconverted() + end +end + +function pyconvert_rule_timedelta64(::Type{TimeDelta64}, x::Py) + pyconvert_return(C.PySimpleObject_GetValue(TimeDelta64, x)) +end + +function pyconvert_rule_timedelta64(::Type{T}, x::Py) where {T<:InlineTimeDelta64} + pyconvert_tryconvert(T, C.PySimpleObject_GetValue(TimeDelta64, x)) +end + +function pyconvert_rule_timedelta64(::Type{T}, x::Py) where {T<:NumpyDates.DatesPeriod} + d = C.PySimpleObject_GetValue(TimeDelta64, x) + if isnan(d) + pyconvert_unconverted() + else + pyconvert_tryconvert(T, d) + end +end + +function pyconvert_rule_timedelta64(::Type{Missing}, x::Py) + d = C.PySimpleObject_GetValue(TimeDelta64, x) + if isnan(d) + pyconvert_return(missing) + else + pyconvert_unconverted() + end +end + +function pyconvert_rule_timedelta64(::Type{Nothing}, x::Py) + d = C.PySimpleObject_GetValue(TimeDelta64, x) + if isnan(d) + pyconvert_return(missing) + else + pyconvert_unconverted() + end +end + const NUMPY_SIMPLE_TYPES = [ ("bool_", Bool), ("int8", Int8), @@ -28,6 +98,7 @@ const NUMPY_SIMPLE_TYPES = [ ] function init_numpy() + # simple numeric scalar types for (t, T) in NUMPY_SIMPLE_TYPES isbool = occursin("bool", t) isint = occursin("int", t) || isbool @@ -54,4 +125,36 @@ function init_numpy() iscomplex && pyconvert_add_rule(name, Complex, rule) isnumber && pyconvert_add_rule(name, Number, rule) end + + # datetime64 + pyconvert_add_rule( + "numpy:datetime64", + DateTime64, + pyconvert_rule_datetime64, + PYCONVERT_PRIORITY_ARRAY, + ) + pyconvert_add_rule("numpy:datetime64", InlineDateTime64, pyconvert_rule_datetime64) + pyconvert_add_rule( + "numpy:datetime64", + NumpyDates.DatesInstant, + pyconvert_rule_datetime64, + ) + pyconvert_add_rule("numpy:datetime64", Missing, pyconvert_rule_datetime64) + pyconvert_add_rule("numpy:datetime64", Nothing, pyconvert_rule_datetime64) + + # timedelta64 + pyconvert_add_rule( + "numpy:timedelta64", + TimeDelta64, + pyconvert_rule_timedelta64, + PYCONVERT_PRIORITY_ARRAY, + ) + pyconvert_add_rule("numpy:timedelta64", InlineTimeDelta64, pyconvert_rule_timedelta64) + pyconvert_add_rule( + "numpy:timedelta64", + NumpyDates.DatesPeriod, + pyconvert_rule_timedelta64, + ) + pyconvert_add_rule("numpy:timedelta64", Missing, pyconvert_rule_timedelta64) + pyconvert_add_rule("numpy:timedelta64", Nothing, pyconvert_rule_timedelta64) end diff --git a/src/JlWrap/JlWrap.jl b/src/JlWrap/JlWrap.jl index 28abcaec..79b9c6b6 100644 --- a/src/JlWrap/JlWrap.jl +++ b/src/JlWrap/JlWrap.jl @@ -7,6 +7,7 @@ module JlWrap using ..PythonCall using ..Utils +using ..NumpyDates: NumpyDates using ..C using ..Core using ..Convert diff --git a/src/JlWrap/array.jl b/src/JlWrap/array.jl index c46b6a0d..f063c191 100644 --- a/src/JlWrap/array.jl +++ b/src/JlWrap/array.jl @@ -223,6 +223,8 @@ pyjlarray_isarrayabletype(::Type{T}) where {T} = T in ( Complex{Float32}, Complex{Float64}, ) +pyjlarray_isarrayabletype(::Type{NumpyDates.InlineDateTime64{U}}) where {U} = true +pyjlarray_isarrayabletype(::Type{NumpyDates.InlineTimeDelta64{U}}) where {U} = true pyjlarray_isarrayabletype(::Type{T}) where {T<:Tuple} = isconcretetype(T) && Base.allocatedinline(T) && @@ -235,22 +237,45 @@ const PYTYPESTRDESCR = IdDict{Type,Tuple{String,Py}}() pytypestrdescr(::Type{T}) where {T} = get!(PYTYPESTRDESCR, T) do c = Utils.islittleendian() ? '<' : '>' - T == Bool ? ("$(c)b$(sizeof(Bool))", PyNULL) : - T == Int8 ? ("$(c)i1", PyNULL) : - T == UInt8 ? ("$(c)u1", PyNULL) : - T == Int16 ? ("$(c)i2", PyNULL) : - T == UInt16 ? ("$(c)u2", PyNULL) : - T == Int32 ? ("$(c)i4", PyNULL) : - T == UInt32 ? ("$(c)u4", PyNULL) : - T == Int64 ? ("$(c)i8", PyNULL) : - T == UInt64 ? ("$(c)u8", PyNULL) : - T == Float16 ? ("$(c)f2", PyNULL) : - T == Float32 ? ("$(c)f4", PyNULL) : - T == Float64 ? ("$(c)f8", PyNULL) : - T == Complex{Float16} ? ("$(c)c4", PyNULL) : - T == Complex{Float32} ? ("$(c)c8", PyNULL) : - T == Complex{Float64} ? ("$(c)c16", PyNULL) : - if isstructtype(T) && isconcretetype(T) && Base.allocatedinline(T) + if T == Bool + ("$(c)b$(sizeof(Bool))", PyNULL) + elseif T == Int8 + ("$(c)i1", PyNULL) + elseif T == UInt8 + ("$(c)u1", PyNULL) + elseif T == Int16 + ("$(c)i2", PyNULL) + elseif T == UInt16 + ("$(c)u2", PyNULL) + elseif T == Int32 + ("$(c)i4", PyNULL) + elseif T == UInt32 + ("$(c)u4", PyNULL) + elseif T == Int64 + ("$(c)i8", PyNULL) + elseif T == UInt64 + ("$(c)u8", PyNULL) + elseif T == Float16 + ("$(c)f2", PyNULL) + elseif T == Float32 + ("$(c)f4", PyNULL) + elseif T == Float64 + ("$(c)f8", PyNULL) + elseif T == Complex{Float16} + ("$(c)c4", PyNULL) + elseif T == Complex{Float32} + ("$(c)c8", PyNULL) + elseif T == Complex{Float64} + ("$(c)c16", PyNULL) + elseif isconcretetype(T) && + T <: Union{NumpyDates.InlineDateTime64,NumpyDates.InlineTimeDelta64} + u, m = NumpyDates.unitpair(T) + tc = T <: NumpyDates.InlineDateTime64 ? 'M' : 'm' + us = + u == NumpyDates.UNBOUND_UNITS ? "" : + m == 1 ? "[$(Symbol(u))]" : "[$(m)$(Symbol(u))]" + ("$(c)$(tc)8$(us)", PyNULL) + elseif isstructtype(T) && isconcretetype(T) && Base.allocatedinline(T) n = fieldcount(T) flds = [] for i = 1:n diff --git a/src/NumpyDates/AbstractDateTime64.jl b/src/NumpyDates/AbstractDateTime64.jl new file mode 100644 index 00000000..76261ec7 --- /dev/null +++ b/src/NumpyDates/AbstractDateTime64.jl @@ -0,0 +1,58 @@ +""" + abstract type AbstractDateTime64 <: Dates.TimeType + +Supertype for [`DateTime64`](@ref) and [`InlineDateTime64`](@ref). +""" +abstract type AbstractDateTime64 <: Dates.TimeType end + +function Dates.DateTime(d::AbstractDateTime64) + isnan(d) && error("Cannot convert NaT to DateTime") + v = value(d) + u, _ = unit = unitpair(d) + b = Dates.DateTime(1970) + if u > MONTHS + v, _ = rescale(v, unit, MILLISECONDS) + b + Dates.Millisecond(v) + else + v, _ = rescale(v, unit, MONTHS) + b + Dates.Month(v) + end +end + +function Dates.Date(d::AbstractDateTime64) + isnan(d) && error("Cannot convert NaT to Date") + Dates.Date(Dates.DateTime(d)) +end + +Base.convert(::Type{Dates.DateTime}, d::AbstractDateTime64) = Dates.DateTime(d) +Base.convert(::Type{Dates.Date}, d::AbstractDateTime64) = Dates.Date(d) + +function Base.isnan(d::AbstractDateTime64) + value(d) == typemin(Int64) +end + +function showvalue(io::IO, d::AbstractDateTime64) + u, m = unit = unitpair(d) + if isnan(d) + show(io, "NaT") + elseif u ≤ DAYS + d2 = Dates.Date(d) + if value(DateTime64(d2, unit)) == value(d) + show(io, string(d2)) + else + show(io, value(d)) + end + else + d2 = Dates.DateTime(d) + if value(DateTime64(d2, unit)) == value(d) + show(io, string(d2)) + else + show(io, value(d)) + end + end + nothing +end + +function defaultunit(d::AbstractDateTime64) + unitpair(d) +end diff --git a/src/NumpyDates/AbstractTimeDelta64.jl b/src/NumpyDates/AbstractTimeDelta64.jl new file mode 100644 index 00000000..52741070 --- /dev/null +++ b/src/NumpyDates/AbstractTimeDelta64.jl @@ -0,0 +1,50 @@ +""" + abstract type AbstractTimeDelta64 <: Dates.Period + +Supertype for [`TimeDelta64`](@ref) and [`InlineTimeDelta64`](@ref). +""" +abstract type AbstractTimeDelta64 <: Dates.Period end + +function construct(::Type{T}, d::AbstractTimeDelta64) where {T<:DatesPeriod} + v, r = rescale(value(d), unitpair(d), Unit(T)) + iszero(r) || throw(InexactError(:convert, T, d)) + T(v) +end + +Dates.Year(d::AbstractTimeDelta64) = construct(Dates.Year, d) +Dates.Month(d::AbstractTimeDelta64) = construct(Dates.Month, d) +Dates.Day(d::AbstractTimeDelta64) = construct(Dates.Day, d) +Dates.Hour(d::AbstractTimeDelta64) = construct(Dates.Hour, d) +Dates.Minute(d::AbstractTimeDelta64) = construct(Dates.Minute, d) +Dates.Second(d::AbstractTimeDelta64) = construct(Dates.Second, d) +Dates.Millisecond(d::AbstractTimeDelta64) = construct(Dates.Millisecond, d) +Dates.Microsecond(d::AbstractTimeDelta64) = construct(Dates.Microsecond, d) +Dates.Nanosecond(d::AbstractTimeDelta64) = construct(Dates.Nanosecond, d) + +Base.convert(::Type{Dates.Year}, d::AbstractTimeDelta64) = Dates.Year(d) +Base.convert(::Type{Dates.Month}, d::AbstractTimeDelta64) = Dates.Month(d) +Base.convert(::Type{Dates.Day}, d::AbstractTimeDelta64) = Dates.Day(d) +Base.convert(::Type{Dates.Hour}, d::AbstractTimeDelta64) = Dates.Hour(d) +Base.convert(::Type{Dates.Minute}, d::AbstractTimeDelta64) = Dates.Minute(d) +Base.convert(::Type{Dates.Second}, d::AbstractTimeDelta64) = Dates.Second(d) +Base.convert(::Type{Dates.Millisecond}, d::AbstractTimeDelta64) = Dates.Millisecond(d) +Base.convert(::Type{Dates.Microsecond}, d::AbstractTimeDelta64) = Dates.Microsecond(d) +Base.convert(::Type{Dates.Nanosecond}, d::AbstractTimeDelta64) = Dates.Nanosecond(d) + +function Base.isnan(d::AbstractTimeDelta64) + value(d) == NAT +end + +function showvalue(io::IO, d::AbstractTimeDelta64) + u, m = unitpair(d) + if isnan(d) + show(io, "NaT") + else + show(io, value(d)) + end + nothing +end + +function defaultunit(d::AbstractTimeDelta64) + unitpair(d) +end diff --git a/src/NumpyDates/DateTime64.jl b/src/NumpyDates/DateTime64.jl new file mode 100644 index 00000000..fad7947e --- /dev/null +++ b/src/NumpyDates/DateTime64.jl @@ -0,0 +1,107 @@ +# type + +""" + DateTime64(value, [unit]) + DateTime64(value, format, [unit]) + +Construct an `DateTime64` with the given `value` and [`unit`](@ref Unit). + +The unit is stored as a run-time value. If the units in your code are known, using +[`InlineDateTime64{unit}`](@ref InlineDateTime64) may be preferable. The memory layout +is the same as for a `numpy.datetime64`. + +The value can be: +- An `Integer`, in which case the `unit` is required. +- A `Dates.Date` or `Dates.DateTime`. +- `"NaT"` or `"NaN"` to make a not-a-time value. +- An `AbstractString`, which is parsed the same as `Dates.DateTime`, with an optional + `format` string. +""" +struct DateTime64 <: AbstractDateTime64 + value::Int64 + unit::Tuple{Unit,Cint} + function DateTime64(value::Integer, unit::UnitArg) + new(Int(value), unitpair(unit)) + end +end + +# accessors + +Dates.value(d::DateTime64) = d.value + +unitpair(d::DateTime64) = d.unit + +# constructors + +function DateTime64(d::AbstractDateTime64, unit::UnitArg = defaultunit(d)) + unit = unitpair(unit) + if unit == unitpair(d) + DateTime64(value(d), unit) + elseif isnan(d) + DateTime64(NAT, unit) + else + v, _ = rescale(value(d), unitpair(d), unit) + DateTime64(v, unit) + end +end + +function DateTime64(d::AbstractString, unit::UnitArg = defaultunit(d)) + unit = unitpair(unit) + if d in NAT_STRINGS + DateTime64(NAT, unit) + else + DateTime64(Dates.DateTime(d), unit) + end +end + +function DateTime64(d::AbstractString, f::Dates.DateFormat, unit::UnitArg = defaultunit(d)) + unit = unitpair(unit) + if d in NAT_STRINGS + DateTime64(NAT, unit) + else + DateTime64(Dates.DateTime(d, f), unit) + end +end + +function DateTime64(d::Dates.DateTime, unit::UnitArg = defaultunit(d)) + u, _ = unit = unitpair(unit) + if u == YEARS + v_yr = sub(Dates.year(d), 1970) + v, _ = rescale(v_yr, YEARS, unit) + elseif u == MONTHS + yr, mn = Dates.yearmonth(d) + v_mn = add(mul(12, sub(yr, 1970)), sub(mn, 1)) + v, _ = rescale(v_mn, MONTHS, unit) + else + v_ms = d - Dates.DateTime(1970) + v, _ = rescale(value(v_ms), Unit(v_ms), unit) + end + DateTime64(v, unit) +end + + +function DateTime64(d::Dates.Date, unit::UnitArg = defaultunit(d)) + DateTime64(Dates.DateTime(d), unit) +end + +# convert + +Base.convert(::Type{DateTime64}, x::DateTime64) = x +Base.convert(::Type{DateTime64}, x::AbstractDateTime64) = DateTime64(x) +Base.convert(::Type{DateTime64}, x::DatesInstant) = DateTime64(x) + +# show + +function Base.show(io::IO, d::DateTime64) + if get(io, :typeinfo, Any) == typeof(d) + showvalue(io, d) + else + show(io, typeof(d)) + print(io, "(") + showvalue(io, d) + print(io, ", ") + show(io, unitparam(unitpair(d))) + print(io, ")") + end + nothing +end diff --git a/src/NumpyDates/InlineDateTime64.jl b/src/NumpyDates/InlineDateTime64.jl new file mode 100644 index 00000000..96a0c4a5 --- /dev/null +++ b/src/NumpyDates/InlineDateTime64.jl @@ -0,0 +1,117 @@ +# type + +""" + InlineDateTime64{unit}(value) + InlineDateTime64{unit}(value, format) + InlineDateTime64(value, [unit]) + InlineDateTime64(value, format, [unit]) + +Construct an `InlineDateTime64` with the given `value` and [`unit`](@ref Unit). + +The unit is part of the type, so an instance just consists of one `Int64` for the value. + +The `value` can be: +- An `Integer`, in which case the `unit` is required. +- A `Dates.Date` or `Dates.DateTime`. +- `"NaT"` or `"NaN"` to make a not-a-time value. +- An `AbstractString`, which is parsed the same as `Dates.DateTime`, with an optional + `format` string. +""" +struct InlineDateTime64{U} <: AbstractDateTime64 + value::Int64 + function InlineDateTime64{U}(value::Int64) where {U} + U isa Unit || + U isa Tuple{Unit,Int} || + error("U must be a Unit or a Tuple{Unit,Int}") + new{U}(value) + end +end + +# accessors + +Dates.value(d::InlineDateTime64) = d.value + +unitpair(::InlineDateTime64{U}) where {U} = unitpair(U) +unitpair(::Type{InlineDateTime64{U}}) where {U} = unitpair(U) + +# constructors + +function InlineDateTime64{U}(v::AbstractDateTime64) where {U} + InlineDateTime64{U}(value(DateTime64(v, U))) +end + +function InlineDateTime64{U}(v::AbstractString) where {U} + InlineDateTime64{U}(value(DateTime64(v, U))) +end + +function InlineDateTime64{U}(v::Dates.DateTime) where {U} + InlineDateTime64{U}(value(DateTime64(v, U))) +end + +function InlineDateTime64{U}(v::Dates.Date) where {U} + InlineDateTime64{U}(value(DateTime64(v, U))) +end + +function InlineDateTime64{U}(v::Integer) where {U} + InlineDateTime64{U}(convert(Int, v)) +end + +function InlineDateTime64{U}( + v::AbstractString, + f::Union{AbstractString,Dates.DateFormat}, +) where {U} + InlineDateTime64{U}(value(DateTime64(v, f, U))) +end + +function InlineDateTime64(v::AbstractDateTime64, u::UnitArg = defaultunit(v)) + InlineDateTime64{unitparam(u)}(v) +end + +function InlineDateTime64(v::AbstractString, u::UnitArg = defaultunit(v)) + InlineDateTime64{unitparam(u)}(v) +end + +function InlineDateTime64(v::Dates.DateTime, u::UnitArg = defaultunit(v)) + InlineDateTime64{unitparam(u)}(v) +end + +function InlineDateTime64(v::Dates.Date, u::UnitArg = defaultunit(v)) + InlineDateTime64{unitparam(u)}(v) +end + +function InlineDateTime64(v::Integer, u::UnitArg) + InlineDateTime64{unitparam(u)}(v) +end + +function InlineDateTime64( + v::AbstractString, + f::Union{AbstractString,Dates.DateFormat}, + u::UnitArg = defaultunit(v), +) + InlineTimeDelta64{unitparam(u)}(v, f) +end + +# convert + +Base.convert(::Type{InlineDateTime64}, x::InlineDateTime64) = x +Base.convert(::Type{InlineDateTime64}, x::Union{AbstractDateTime64,DatesInstant}) = + InlineDateTime64(x) +Base.convert(::Type{InlineDateTime64{U}}, x::InlineDateTime64{U}) where {U} = x +Base.convert( + ::Type{InlineDateTime64{U}}, + x::Union{AbstractDateTime64,DatesInstant}, +) where {U} = InlineDateTime64{U}(x) + +# show + +function Base.show(io::IO, d::InlineDateTime64) + if get(io, :typeinfo, Any) == typeof(d) + showvalue(io, d) + else + show(io, typeof(d)) + print(io, "(") + showvalue(io, d) + print(io, ")") + end + nothing +end diff --git a/src/NumpyDates/InlineTimeDelta64.jl b/src/NumpyDates/InlineTimeDelta64.jl new file mode 100644 index 00000000..62d9e19f --- /dev/null +++ b/src/NumpyDates/InlineTimeDelta64.jl @@ -0,0 +1,91 @@ +# type + +""" + InlineTimeDelta64{unit}(value) + InlineTimeDelta64(value, [unit]) + +Construct an `InlineTimeDelta64` with the given `value` and [`unit`](@ref Unit). + +The unit is part of the type, so an instance just consists of one `Int64` for the value. + +The value can be: +- An `Integer`, in which case the `unit` is required. +- `"NaT"` or `"NaN"` to make a not-a-time value. +""" +struct InlineTimeDelta64{U} <: AbstractTimeDelta64 + value::Int64 + function InlineTimeDelta64{U}(value::Int64) where {U} + U isa Unit || + U isa Tuple{Unit,Int} || + error("U must be a Unit or a Tuple{Unit,Int}") + new{U}(value) + end +end + +# accessors + +Dates.value(d::InlineTimeDelta64) = d.value + +unitpair(::InlineTimeDelta64{U}) where {U} = unitpair(U) +unitpair(::Type{InlineTimeDelta64{U}}) where {U} = unitpair(U) + +# constructors + +function InlineTimeDelta64{U}(v::AbstractTimeDelta64) where {U} + InlineTimeDelta64{U}(value(TimeDelta64(v, U))) +end + +function InlineTimeDelta64{U}(v::AbstractString) where {U} + InlineTimeDelta64{U}(value(TimeDelta64(v, U))) +end + +function InlineTimeDelta64{U}(v::DatesPeriod) where {U} + InlineTimeDelta64{U}(value(TimeDelta64(v, U))) +end + +function InlineTimeDelta64{U}(v::Integer) where {U} + InlineTimeDelta64{U}(convert(Int, v)) +end + +function InlineTimeDelta64(v::AbstractTimeDelta64, u::UnitArg = defaultunit(v)) + InlineTimeDelta64{unitparam(u)}(v) +end + +function InlineTimeDelta64(v::AbstractString, u::UnitArg = defaultunit(v)) + InlineTimeDelta64{unitparam(u)}(v) +end + +function InlineTimeDelta64(v::DatesPeriod, u::UnitArg = defaultunit(v)) + InlineTimeDelta64{unitparam(u)}(v) +end + +function InlineTimeDelta64(v::Integer, u::UnitArg) + InlineTimeDelta64{unitparam(u)}(v) +end + +# convert + +Base.convert(::Type{InlineTimeDelta64}, p::InlineTimeDelta64) = p +Base.convert(::Type{InlineTimeDelta64}, p::Union{AbstractTimeDelta64,DatesPeriod}) = + InlineTimeDelta64(p) +Base.convert(::Type{InlineTimeDelta64{U}}, p::InlineTimeDelta64{U}) where {U} = p +Base.convert( + ::Type{InlineTimeDelta64{U}}, + p::Union{AbstractTimeDelta64,DatesPeriod}, +) where {U} = InlineTimeDelta64{U}(p) + +# show + +function Base.show(io::IO, d::InlineTimeDelta64) + if get(io, :typeinfo, Any) == typeof(d) + showvalue(io, d) + else + show(io, typeof(d)) + print(io, "(") + showvalue(io, d) + print(io, ")") + end + nothing +end + +Base.show(io::IO, ::MIME"text/plain", d::InlineTimeDelta64) = show(io, d) diff --git a/src/NumpyDates/NumpyDates.jl b/src/NumpyDates/NumpyDates.jl new file mode 100644 index 00000000..1fd51ffd --- /dev/null +++ b/src/NumpyDates/NumpyDates.jl @@ -0,0 +1,45 @@ +""" + module NumpyDates + +Provides datetimes and timedeltas compatible with Numpy. + +See: [`DateTime64`](@ref), [`InlineDateTime64`](@ref), [`TimeDelta64`](@ref) and +[`InlineTimeDelta64`](@ref). + +These can generally be converted to/from their respective types in the `Dates` stdlib. +""" +module NumpyDates + +using Dates: Dates, value + +export Unit, + YEARS, + MONTHS, + WEEKS, + DAYS, + HOURS, + MINUTES, + SECONDS, + MILLISECONDS, + MICROSECONDS, + NANOSECONDS, + PICOSECONDS, + FEMTOSECONDS, + ATTOSECONDS, + AbstractDateTime64, + InlineDateTime64, + DateTime64, + AbstractTimeDelta64, + InlineTimeDelta64, + TimeDelta64 + +include("common.jl") +include("Unit.jl") +include("AbstractDateTime64.jl") +include("InlineDateTime64.jl") +include("DateTime64.jl") +include("AbstractTimeDelta64.jl") +include("InlineTimeDelta64.jl") +include("TimeDelta64.jl") + +end diff --git a/src/NumpyDates/TimeDelta64.jl b/src/NumpyDates/TimeDelta64.jl new file mode 100644 index 00000000..ab0f9b69 --- /dev/null +++ b/src/NumpyDates/TimeDelta64.jl @@ -0,0 +1,86 @@ +# type + +""" + TimeDelta64(value, [unit]) + +Construct a `TimeDelta64` with the given `value` and [`unit`](@ref Unit). + +The unit is stored as a run-time value. If the units in your code are known, using +[`InlineTimeDelta64{unit}`](@ref InlineTimeDelta64) may be preferable. The memory layout +is the same as for a `numpy.timedelta64`. + +The value can be: +- An `Integer`, in which case the `unit` is required. +- `"NaT"` or `"NaN"` to make a not-a-time value. +""" +struct TimeDelta64 <: AbstractTimeDelta64 + value::Int64 + unit::Tuple{Unit,Cint} + function TimeDelta64(value::Integer, unit::UnitArg) + new(Int(value), unitpair(unit)) + end +end + +# accessors + +Dates.value(d::TimeDelta64) = d.value + +unitpair(d::TimeDelta64) = d.unit + +# constructors + +# (outer value/unit constructor is unnecessary; inner constructor handles UnitArg) + +function TimeDelta64(d::AbstractTimeDelta64, unit::UnitArg = defaultunit(d)) + unit = unitpair(unit) + if unit == unitpair(d) + TimeDelta64(value(d), unit) + elseif isnan(d) + TimeDelta64(NAT, unit) + else + v, r = rescale(value(d), unitpair(d), unit) + iszero(r) || throw(InexactError(:convert, TimeDelta64, d)) + TimeDelta64(v, unit) + end +end + +function TimeDelta64(s::AbstractString, unit::UnitArg = defaultunit(s)) + unit = unitpair(unit) + if s in NAT_STRINGS + TimeDelta64(NAT, unit) + else + error( + "Cannot construct TimeDelta64 from string '$s'. Only NaT variants are supported.", + ) + end +end + +function TimeDelta64(p::DatesPeriod, unit::UnitArg = defaultunit(p)) + v, r = rescale(value(p), Unit(p), unit) + iszero(r) || throw(InexactError(:convert, TimeDelta64, p)) + TimeDelta64(v, unit) +end + +# convert + +Base.convert(::Type{TimeDelta64}, p::TimeDelta64) = p +Base.convert(::Type{TimeDelta64}, p::Union{AbstractTimeDelta64,DatesPeriod}) = + TimeDelta64(p) + +# show + +function Base.show(io::IO, d::TimeDelta64) + if get(io, :typeinfo, Any) == typeof(d) + showvalue(io, d) + else + show(io, typeof(d)) + print(io, "(") + showvalue(io, d) + print(io, ", ") + show(io, unitparam(unitpair(d))) + print(io, ")") + end + nothing +end + +Base.show(io::IO, ::MIME"text/plain", d::TimeDelta64) = show(io, d) diff --git a/src/NumpyDates/Unit.jl b/src/NumpyDates/Unit.jl new file mode 100644 index 00000000..9cc6a73c --- /dev/null +++ b/src/NumpyDates/Unit.jl @@ -0,0 +1,442 @@ +""" + @enum Unit + +The possible time units for datetimes and timedeltas in this module. + +Values are: `YEARS`, `MONTHS`, `WEEKS`, `DAYS`, `HOURS`, `MINUTES`, `SECONDS`, +`MILLISECONDS`, `MICROSECONDS`, `NANOSECONDS`, `PICOSECONDS`, `FEMTOSECONDS`, +`ATTOSECONDS`. + +For compatibility with numpy, the types in this module also accept scaled units as a +`Tuple{Unit,Int}`. For example `(MINUTES, 15)` for units of 15 minutes. This feature is +rarely used. +""" +@enum Unit::Cint begin + YEARS = 0 + MONTHS = 1 + WEEKS = 2 + DAYS = 4 + HOURS = 5 + MINUTES = 6 + SECONDS = 7 + MILLISECONDS = 8 + MICROSECONDS = 9 + NANOSECONDS = 10 + PICOSECONDS = 11 + FEMTOSECONDS = 12 + ATTOSECONDS = 13 + UNBOUND_UNITS = 14 +end + +function Unit(u::Unit) + u +end + +function Unit(u::Symbol) + u == :Y ? YEARS : + u == :M ? MONTHS : + u == :W ? WEEKS : + u == :D ? DAYS : + u == :h ? HOURS : + u == :m ? MINUTES : + u == :s ? SECONDS : + u == :ms ? MILLISECONDS : + u == :us ? MICROSECONDS : + u == :ns ? NANOSECONDS : + u == :ps ? PICOSECONDS : + u == :fs ? FEMTOSECONDS : u == :as ? ATTOSECONDS : error("invalid unit: $u") +end + +Unit(::Type{Dates.Date}) = DAYS +Unit(::Type{Dates.DateTime}) = MILLISECONDS +Unit(::Type{Dates.Year}) = YEARS +Unit(::Type{Dates.Month}) = MONTHS +Unit(::Type{Dates.Week}) = WEEKS +Unit(::Type{Dates.Day}) = DAYS +Unit(::Type{Dates.Hour}) = HOURS +Unit(::Type{Dates.Minute}) = MINUTES +Unit(::Type{Dates.Second}) = SECONDS +Unit(::Type{Dates.Millisecond}) = MILLISECONDS +Unit(::Type{Dates.Microsecond}) = MICROSECONDS +Unit(::Type{Dates.Nanosecond}) = NANOSECONDS + +Unit(d::DatesInstant) = Unit(typeof(d)) +Unit(p::DatesPeriod) = Unit(typeof(p)) + +function Base.Symbol(u::Unit) + u == NumpyDates.YEARS ? :Y : + u == NumpyDates.MONTHS ? :M : + u == NumpyDates.WEEKS ? :W : + u == NumpyDates.DAYS ? :D : + u == NumpyDates.HOURS ? :h : + u == NumpyDates.MINUTES ? :m : + u == NumpyDates.SECONDS ? :s : + u == NumpyDates.MILLISECONDS ? :ms : + u == NumpyDates.MICROSECONDS ? :us : + u == NumpyDates.NANOSECONDS ? :ns : + u == NumpyDates.PICOSECONDS ? :ps : + u == NumpyDates.FEMTOSECONDS ? :fs : + u == NumpyDates.ATTOSECONDS ? :as : error("invalid unit: $u") +end + +const UnitArg = Union{Unit,Symbol,Tuple{Unit,Integer},Tuple{Symbol,Integer}} + +const UnitPair = Tuple{Unit,Integer} + +function unitpair(u::UnitArg) + u, m = u isa Tuple ? u : (u, 1) + (Unit(u), m) +end + +function unitparam(u::UnitArg) + u, m = unitpair(u) + m == 1 ? u : (u, Int(m)) +end + +defaultunit(::Any) = NANOSECONDS +defaultunit(d::DatesInstant) = Unit(d) +defaultunit(p::DatesPeriod) = Unit(p) + +""" + unitscale(u0::Unit, u1::Unit) + +Returns `(multiplier, divisor)` to go from a value in units `u0` to units `u1`. + +For example `unitscale(YEARS, MONTHS)` returns `(12, 1)` because 1 year is 12 months. +""" +function unitscale(u0::Unit, u1::Unit) + m::Int64 = 1 + d::Int64 = 1 + c(n) = n isa Int128 ? Int64(0) : Int64(n) + if u0 == u1 + # ok + elseif u0 == YEARS + if u1 == YEARS + # ok + elseif u1 == MONTHS + m = c(12) + else + m = 0 + end + elseif u0 == MONTHS + if u1 == YEARS + d = c(12) + elseif u1 == MONTHS + # ok + else + m = 0 + end + elseif u0 == WEEKS + if u1 == WEEKS + # ok + elseif u1 == DAYS + m = c(7) + elseif u1 == HOURS + m = c(168) + elseif u1 == MINUTES + m = c(10_080) + elseif u1 == SECONDS + m = c(604_800) + elseif u1 == MILLISECONDS + m = c(604_800_000) + elseif u1 == MICROSECONDS + m = c(604_800_000_000) + elseif u1 == NANOSECONDS + m = c(604_800_000_000_000) + elseif u1 == PICOSECONDS + m = c(604_800_000_000_000_000) + elseif u1 == FEMTOSECONDS + m = c(604_800_000_000_000_000_000) + elseif u1 == ATTOSECONDS + m = c(604_800_000_000_000_000_000_000) + else + m = 0 + end + elseif u0 == DAYS + if u1 == WEEKS + d = c(7) + elseif u1 == DAYS + # ok + elseif u1 == HOURS + m = c(24) + elseif u1 == MINUTES + m = c(1_440) + elseif u1 == SECONDS + m = c(86_400) + elseif u1 == MILLISECONDS + m = c(86_400_000) + elseif u1 == MICROSECONDS + m = c(86_400_000_000) + elseif u1 == NANOSECONDS + m = c(86_400_000_000_000) + elseif u1 == PICOSECONDS + m = c(86_400_000_000_000_000) + elseif u1 == FEMTOSECONDS + m = c(86_400_000_000_000_000_000) + elseif u1 == ATTOSECONDS + m = c(86_400_000_000_000_000_000_000) + else + m = 0 + end + elseif u0 == HOURS + if u1 == WEEKS + d = c(168) + elseif u1 == DAYS + d = c(24) + elseif u1 == HOURS + # ok + elseif u1 == MINUTES + m = c(60) + elseif u1 == SECONDS + m = c(3_600) + elseif u1 == MILLISECONDS + m = c(3_600_000) + elseif u1 == MICROSECONDS + m = c(3_600_000_000) + elseif u1 == NANOSECONDS + m = c(3_600_000_000_000) + elseif u1 == PICOSECONDS + m = c(3_600_000_000_000_000) + elseif u1 == FEMTOSECONDS + m = c(3_600_000_000_000_000_000) + elseif u1 == ATTOSECONDS + m = c(3_600_000_000_000_000_000_000) + else + m = 0 + end + elseif u0 == MINUTES + if u1 == WEEKS + d = c(10_080) + elseif u1 == DAYS + d = c(1_440) + elseif u1 == HOURS + d = c(60) + elseif u1 == MINUTES + # ok + elseif u1 == SECONDS + m = c(60) + elseif u1 == MILLISECONDS + m = c(60_000) + elseif u1 == MICROSECONDS + m = c(60_000_000) + elseif u1 == NANOSECONDS + m = c(60_000_000_000) + elseif u1 == PICOSECONDS + m = c(60_000_000_000_000) + elseif u1 == FEMTOSECONDS + m = c(60_000_000_000_000_000) + elseif u1 == ATTOSECONDS + m = c(60_000_000_000_000_000_000) + else + m = 0 + end + elseif u0 == SECONDS + if u1 == WEEKS + d = c(604_800) + elseif u1 == DAYS + d = c(86_400) + elseif u1 == HOURS + d = c(3_600) + elseif u1 == MINUTES + d = c(60) + elseif u1 == SECONDS + # ok + elseif u1 == MILLISECONDS + m = c(1_000) + elseif u1 == MICROSECONDS + m = c(1_000_000) + elseif u1 == NANOSECONDS + m = c(1_000_000_000) + elseif u1 == PICOSECONDS + m = c(1_000_000_000_000) + elseif u1 == FEMTOSECONDS + m = c(1_000_000_000_000_000) + elseif u1 == ATTOSECONDS + m = c(1_000_000_000_000_000_000) + else + m = 0 + end + elseif u0 == MILLISECONDS + if u1 == WEEKS + d = c(604_800_000) + elseif u1 == DAYS + d = c(86_400_000) + elseif u1 == HOURS + d = c(3_600_000) + elseif u1 == MINUTES + d = c(60_000) + elseif u1 == SECONDS + d = c(1_000) + elseif u1 == MILLISECONDS + # ok + elseif u1 == MICROSECONDS + m = c(1_000) + elseif u1 == NANOSECONDS + m = c(1_000_000) + elseif u1 == PICOSECONDS + m = c(1_000_000_000) + elseif u1 == FEMTOSECONDS + m = c(1_000_000_000_000) + elseif u1 == ATTOSECONDS + m = c(1_000_000_000_000_000) + else + m = 0 + end + elseif u0 == MICROSECONDS + if u1 == WEEKS + d = c(604_800_000_000) + elseif u1 == DAYS + d = c(86_400_000_000) + elseif u1 == HOURS + d = c(3_600_000_000) + elseif u1 == MINUTES + d = c(60_000_000) + elseif u1 == SECONDS + d = c(1_000_000) + elseif u1 == MILLISECONDS + d = c(1_000) + elseif u1 == MICROSECONDS + # ok + elseif u1 == NANOSECONDS + m = c(1_000) + elseif u1 == PICOSECONDS + m = c(1_000_000) + elseif u1 == FEMTOSECONDS + m = c(1_000_000_000) + elseif u1 == ATTOSECONDS + m = c(1_000_000_000_000) + else + m = 0 + end + elseif u0 == NANOSECONDS + if u1 == WEEKS + d = c(604_800_000_000_000) + elseif u1 == DAYS + d = c(86_400_000_000_000) + elseif u1 == HOURS + d = c(3_600_000_000_000) + elseif u1 == MINUTES + d = c(60_000_000_000) + elseif u1 == SECONDS + d = c(1_000_000_000) + elseif u1 == MILLISECONDS + d = c(1_000_000) + elseif u1 == MICROSECONDS + d = c(1_000) + elseif u1 == NANOSECONDS + # ok + elseif u1 == PICOSECONDS + m = c(1_000) + elseif u1 == FEMTOSECONDS + m = c(1_000_000) + elseif u1 == ATTOSECONDS + m = c(1_000_000_000) + else + m = 0 + end + elseif u0 == PICOSECONDS + if u1 == WEEKS + d = c(604_800_000_000_000_000) + elseif u1 == DAYS + d = c(86_400_000_000_000_000) + elseif u1 == HOURS + d = c(3_600_000_000_000_000) + elseif u1 == MINUTES + d = c(60_000_000_000_000) + elseif u1 == SECONDS + d = c(1_000_000_000_000) + elseif u1 == MILLISECONDS + d = c(1_000_000_000) + elseif u1 == MICROSECONDS + d = c(1_000_000) + elseif u1 == NANOSECONDS + d = c(1_000) + elseif u1 == PICOSECONDS + # ok + elseif u1 == FEMTOSECONDS + m = c(1_000) + elseif u1 == ATTOSECONDS + m = c(1_000_000) + else + m = 0 + end + elseif u0 == FEMTOSECONDS + if u1 == WEEKS + d = c(604_800_000_000_000_000_000) + elseif u1 == DAYS + d = c(86_400_000_000_000_000_000) + elseif u1 == HOURS + d = c(3_600_000_000_000_000_000) + elseif u1 == MINUTES + d = c(60_000_000_000_000_000) + elseif u1 == SECONDS + d = c(1_000_000_000_000_000) + elseif u1 == MILLISECONDS + d = c(1_000_000_000_000) + elseif u1 == MICROSECONDS + d = c(1_000_000_000) + elseif u1 == NANOSECONDS + d = c(1_000_000) + elseif u1 == PICOSECONDS + d = c(1_000) + elseif u1 == FEMTOSECONDS + # ok + elseif u1 == ATTOSECONDS + m = c(1_000) + else + m = 0 + end + elseif u0 == ATTOSECONDS + if u1 == WEEKS + d = c(604_800_000_000_000_000_000_000) + elseif u1 == DAYS + d = c(86_400_000_000_000_000_000_000) + elseif u1 == HOURS + d = c(3_600_000_000_000_000_000_000) + elseif u1 == MINUTES + d = c(60_000_000_000_000_000_000) + elseif u1 == SECONDS + d = c(1_000_000_000_000_000_000) + elseif u1 == MILLISECONDS + d = c(1_000_000_000_000_000) + elseif u1 == MICROSECONDS + d = c(1_000_000_000_000) + elseif u1 == NANOSECONDS + d = c(1_000_000_000) + elseif u1 == PICOSECONDS + d = c(1_000_000) + elseif u1 == FEMTOSECONDS + d = c(1_000) + elseif u1 == ATTOSECONDS + # ok + else + m = 0 + end + else + m = 0 + end + (m == 0 || d == 0) && error("cannot convert from $u0 to $u1") + (m, d) +end + +""" + rescale(value::Int64, from_unit, to_unit) + +Given a `value` in a given `from_unit`, rescale it to be in the `to_unit`. + +Returns `(to_value, remainder)` where `remainder` + +For example `rescale(2, YEARS, MONTHS) == (24, 0)` because 2 years is exactly 24 months. + +And `rescale(2001, MILLISECONDS, SECONDS) == (2, 1)` because 2001 milliseconds is 2 +seconds and 1 millisecond. +""" +function rescale(v::Int64, from_unit, to_unit) + iszero(v) && return (v, zero(Int64)) + u0::Unit, m0::Int64 = unitpair(from_unit) + u1::Unit, m1::Int64 = unitpair(to_unit) + (multiplier, divisor) = unitscale(u0, u1) + multiplier = mul(multiplier, m0) + divisor = mul(divisor, m1) + fldmod(mul(v, multiplier), divisor) +end diff --git a/src/NumpyDates/common.jl b/src/NumpyDates/common.jl new file mode 100644 index 00000000..85e3084e --- /dev/null +++ b/src/NumpyDates/common.jl @@ -0,0 +1,43 @@ +const NAT = typemin(Int64) + +const NAT_STRINGS = ("NaT", "nat", "NAT", "NaN", "nan", "NAN") + +const DatesPeriod = Union{ + Dates.Year, + Dates.Month, + Dates.Week, + Dates.Day, + Dates.Hour, + Dates.Minute, + Dates.Second, + Dates.Millisecond, + Dates.Microsecond, + Dates.Nanosecond, +} + +const DatesInstant = Union{Dates.Date,Dates.DateTime} + +function add(x::Integer, y::Integer) + ans, err = Base.Checked.add_with_overflow(Int64(x), Int64(y)) + (err || ans == NAT) && throw(OverflowError("add")) + ans +end + +function sub(x::Integer, y::Integer) + ans, err = Base.Checked.sub_with_overflow(Int64(x), Int64(y)) + (err || ans == NAT) && throw(OverflowError("sub")) + ans +end + +function mul(x::Integer, y::Integer) + ans, err = Base.Checked.mul_with_overflow(Int64(x), Int64(y)) + (err || ans == NAT) && throw(OverflowError("mul")) + ans +end + +function convert_and_check(::Type{T}, x) where {T} + ans = T(x)::T + x2 = typeof(x)(ans) + x == x2 || throw(InexactError(:convert, T, x)) + ans +end diff --git a/src/PythonCall.jl b/src/PythonCall.jl index cee7d7f3..46c89c89 100644 --- a/src/PythonCall.jl +++ b/src/PythonCall.jl @@ -4,6 +4,7 @@ const ROOT_DIR = dirname(@__DIR__) include("API/API.jl") include("Utils/Utils.jl") +include("NumpyDates/NumpyDates.jl") include("C/C.jl") include("GIL/GIL.jl") include("GC/GC.jl") diff --git a/src/Wrap/PyArray.jl b/src/Wrap/PyArray.jl index 782b26c0..7b1dc8d3 100644 --- a/src/Wrap/PyArray.jl +++ b/src/Wrap/PyArray.jl @@ -268,6 +268,36 @@ pyarray_typestrdescr_to_type(ts::String, descr::Py) = begin sizeof(T) == sz || error("size mismatch: itemsize=$sz but sizeof(descr)=$(sizeof(T))") return T + elseif etc == 'M' || etc == 'm' + m = match(r"^([0-9]+)(\[([0-9]+)?([a-zA-Z]+)\])?$", ts[3:end]) + m === nothing && error("could not parse type: $ts") + sz = parse(Int, m[1]) + sz == sizeof(Int64) || error( + "$(etc == 'M' ? "datetime" : "timedelta") of this size not supported: $sz", + ) + s = m[3] === nothing ? 1 : parse(Int, m[3]) + us = m[4] === nothing ? "" : m[4] + u = + us == "" ? NumpyDates.UNBOUND_UNITS : + us == "Y" ? NumpyDates.YEARS : + us == "M" ? NumpyDates.MONTHS : + us == "W" ? NumpyDates.WEEKS : + us == "D" ? NumpyDates.DAYS : + us == "h" ? NumpyDates.HOURS : + us == "m" ? NumpyDates.MINUTES : + us == "s" ? NumpyDates.SECONDS : + us == "ms" ? NumpyDates.MILLISECONDS : + us == "us" ? NumpyDates.MICROSECONDS : + us == "ns" ? NumpyDates.NANOSECONDS : + us == "ps" ? NumpyDates.PICOSECONDS : + us == "fs" ? NumpyDates.FEMTOSECONDS : + us == "as" ? NumpyDates.ATTOSECONDS : + error("not supported: $(etc == 'M' ? "datetime" : "timedelta") unit: $us") + if etc == 'M' + return NumpyDates.InlineDateTime64{s == 1 ? u : (u, s)} + else + return NumpyDates.InlineTimeDelta64{s == 1 ? u : (u, s)} + end else error("not supported: dtype of kind: $(repr(etc))") end diff --git a/src/Wrap/Wrap.jl b/src/Wrap/Wrap.jl index 264c869e..e67129cb 100644 --- a/src/Wrap/Wrap.jl +++ b/src/Wrap/Wrap.jl @@ -7,20 +7,14 @@ module Wrap using ..PythonCall using ..Utils +using ..NumpyDates using ..C using ..Core using ..Convert using ..PyMacro import ..PythonCall: - PyArray, - PyDict, - PyIO, - PyIterable, - PyList, - PyPandasDataFrame, - PySet, - PyTable + PyArray, PyDict, PyIO, PyIterable, PyList, PyPandasDataFrame, PySet, PyTable using Base: @propagate_inbounds using Tables: Tables diff --git a/test/NumpyDates.jl b/test/NumpyDates.jl new file mode 100644 index 00000000..c59d8b8b --- /dev/null +++ b/test/NumpyDates.jl @@ -0,0 +1,967 @@ +@testitem "DateTime64 from Date" begin + using Dates + using PythonCall: NumpyDates + + # Mapping from NumPy unit symbols to NumpyDates Unit constants + const UNIT_CONST = Dict( + :Y => NumpyDates.YEARS, + :M => NumpyDates.MONTHS, + :D => NumpyDates.DAYS, + :h => NumpyDates.HOURS, + :m => NumpyDates.MINUTES, + :s => NumpyDates.SECONDS, + :ms => NumpyDates.MILLISECONDS, + :us => NumpyDates.MICROSECONDS, + :ns => NumpyDates.NANOSECONDS, + ) + + # Data generated by: uv run scripts/np_dates_all_units.py + # Format: (Date, unit_symbol, numpy_int_value) + cases = [ + (Date(1969, 12, 31), :Y, -1), + (Date(1969, 12, 31), :M, -1), + (Date(1969, 12, 31), :D, -1), + (Date(1969, 12, 31), :h, -24), + (Date(1969, 12, 31), :m, -1440), + (Date(1969, 12, 31), :s, -86400), + (Date(1969, 12, 31), :ms, -86400000), + (Date(1969, 12, 31), :us, -86400000000), + (Date(1969, 12, 31), :ns, -86400000000000), + (Date(1970, 1, 1), :Y, 0), + (Date(1970, 1, 1), :M, 0), + (Date(1970, 1, 1), :D, 0), + (Date(1970, 1, 1), :h, 0), + (Date(1970, 1, 1), :m, 0), + (Date(1970, 1, 1), :s, 0), + (Date(1970, 1, 1), :ms, 0), + (Date(1970, 1, 1), :us, 0), + (Date(1970, 1, 1), :ns, 0), + (Date(1970, 1, 2), :Y, 0), + (Date(1970, 1, 2), :M, 0), + (Date(1970, 1, 2), :D, 1), + (Date(1970, 1, 2), :h, 24), + (Date(1970, 1, 2), :m, 1440), + (Date(1970, 1, 2), :s, 86400), + (Date(1970, 1, 2), :ms, 86400000), + (Date(1970, 1, 2), :us, 86400000000), + (Date(1970, 1, 2), :ns, 86400000000000), + (Date(1999, 12, 31), :Y, 29), + (Date(1999, 12, 31), :M, 359), + (Date(1999, 12, 31), :D, 10956), + (Date(1999, 12, 31), :h, 262944), + (Date(1999, 12, 31), :m, 15776640), + (Date(1999, 12, 31), :s, 946598400), + (Date(1999, 12, 31), :ms, 946598400000), + (Date(1999, 12, 31), :us, 946598400000000), + (Date(1999, 12, 31), :ns, 946598400000000000), + (Date(2000, 2, 29), :Y, 30), + (Date(2000, 2, 29), :M, 361), + (Date(2000, 2, 29), :D, 11016), + (Date(2000, 2, 29), :h, 264384), + (Date(2000, 2, 29), :m, 15863040), + (Date(2000, 2, 29), :s, 951782400), + (Date(2000, 2, 29), :ms, 951782400000), + (Date(2000, 2, 29), :us, 951782400000000), + (Date(2000, 2, 29), :ns, 951782400000000000), + (Date(1900, 1, 1), :Y, -70), + (Date(1900, 1, 1), :M, -840), + (Date(1900, 1, 1), :D, -25567), + (Date(1900, 1, 1), :h, -613608), + (Date(1900, 1, 1), :m, -36816480), + (Date(1900, 1, 1), :s, -2208988800), + (Date(1900, 1, 1), :ms, -2208988800000), + (Date(1900, 1, 1), :us, -2208988800000000), + (Date(1900, 1, 1), :ns, -2208988800000000000), + (Date(2100, 1, 1), :Y, 130), + (Date(2100, 1, 1), :M, 1560), + (Date(2100, 1, 1), :D, 47482), + (Date(2100, 1, 1), :h, 1139568), + (Date(2100, 1, 1), :m, 68374080), + (Date(2100, 1, 1), :s, 4102444800), + (Date(2100, 1, 1), :ms, 4102444800000), + (Date(2100, 1, 1), :us, 4102444800000000), + (Date(2100, 1, 1), :ns, 4102444800000000000), + ] + + @testset "$d $usym" for (d, usym, expected) in cases + # 1) DateTime64 with UnitArg symbol + dt64 = NumpyDates.DateTime64(d, usym) + @test Dates.value(dt64) == expected + + # 2) InlineDateTime64 with type parameter Unit constant + Uconst = UNIT_CONST[usym] + inline_typed = NumpyDates.InlineDateTime64{Uconst}(d) + @test Dates.value(inline_typed) == expected + + # 3) InlineDateTime64 with runtime UnitArg symbol + inline_dyn = NumpyDates.InlineDateTime64(d, usym) + @test Dates.value(inline_dyn) == expected + @test NumpyDates.unitpair(inline_dyn) == NumpyDates.unitpair(usym) + end +end + +@testitem "DateTime64 from String" begin + using Dates + using PythonCall: NumpyDates + + # Data generated by: uv run test/scripts/np_dates.py + # Format: (string_date, unit_symbol, numpy_int_value) + cases = [ + ("1969-12-31", :Y, -1), + ("1969-12-31", :M, -1), + ("1969-12-31", :D, -1), + ("1969-12-31", :h, -24), + ("1969-12-31", :m, -1440), + ("1969-12-31", :s, -86400), + ("1969-12-31", :ms, -86400000), + ("1969-12-31", :us, -86400000000), + ("1969-12-31", :ns, -86400000000000), + ("1970-01-01", :Y, 0), + ("1970-01-01", :M, 0), + ("1970-01-01", :D, 0), + ("1970-01-01", :h, 0), + ("1970-01-01", :m, 0), + ("1970-01-01", :s, 0), + ("1970-01-01", :ms, 0), + ("1970-01-01", :us, 0), + ("1970-01-01", :ns, 0), + ("1970-01-02", :Y, 0), + ("1970-01-02", :M, 0), + ("1970-01-02", :D, 1), + ("1970-01-02", :h, 24), + ("1970-01-02", :m, 1440), + ("1970-01-02", :s, 86400), + ("1970-01-02", :ms, 86400000), + ("1970-01-02", :us, 86400000000), + ("1970-01-02", :ns, 86400000000000), + ("1999-12-31", :Y, 29), + ("1999-12-31", :M, 359), + ("1999-12-31", :D, 10956), + ("1999-12-31", :h, 262944), + ("1999-12-31", :m, 15776640), + ("1999-12-31", :s, 946598400), + ("1999-12-31", :ms, 946598400000), + ("1999-12-31", :us, 946598400000000), + ("1999-12-31", :ns, 946598400000000000), + ("2000-02-29", :Y, 30), + ("2000-02-29", :M, 361), + ("2000-02-29", :D, 11016), + ("2000-02-29", :h, 264384), + ("2000-02-29", :m, 15863040), + ("2000-02-29", :s, 951782400), + ("2000-02-29", :ms, 951782400000), + ("2000-02-29", :us, 951782400000000), + ("2000-02-29", :ns, 951782400000000000), + ("1900-01-01", :Y, -70), + ("1900-01-01", :M, -840), + ("1900-01-01", :D, -25567), + ("1900-01-01", :h, -613608), + ("1900-01-01", :m, -36816480), + ("1900-01-01", :s, -2208988800), + ("1900-01-01", :ms, -2208988800000), + ("1900-01-01", :us, -2208988800000000), + ("1900-01-01", :ns, -2208988800000000000), + ("2100-01-01", :Y, 130), + ("2100-01-01", :M, 1560), + ("2100-01-01", :D, 47482), + ("2100-01-01", :h, 1139568), + ("2100-01-01", :m, 68374080), + ("2100-01-01", :s, 4102444800), + ("2100-01-01", :ms, 4102444800000), + ("2100-01-01", :us, 4102444800000000), + ("2100-01-01", :ns, 4102444800000000000), + ] + + @testset "$s $usym" for (s, usym, expected) in cases + # DateTime64 from string + dt64 = NumpyDates.DateTime64(s, usym) + @test Dates.value(dt64) == expected + + # Inline typed from string + Uconst = NumpyDates.Unit(usym) + inline_typed = NumpyDates.InlineDateTime64{Uconst}(s) + @test Dates.value(inline_typed) == expected + + # Inline dynamic from string + inline_dyn = NumpyDates.InlineDateTime64(s, usym) + @test Dates.value(inline_dyn) == expected + @test NumpyDates.unitpair(inline_dyn) == NumpyDates.unitpair(usym) + end + + # NaT string handling (all should produce NaT) + @testset "$nat" for nat in ("NaT", "nan", "NAN") + d1 = NumpyDates.DateTime64(nat, :D) + @test isnan(d1) + d2 = NumpyDates.InlineDateTime64{NumpyDates.DAYS}(nat) + @test isnan(d2) + d3 = NumpyDates.InlineDateTime64(nat, :D) + @test isnan(d3) + end +end + +@testitem "DateTime64 from String and DateFormat" begin + using Dates + using PythonCall: NumpyDates + + # Data based on: uv run test/scripts/np_dates.py and uv run test/scripts/np_datetimes.py + # We deliberately use formats that differ from the default parser to exercise the (str, format, unit) constructors. + cases = [ + # date-only with custom format + ("1999/12/31", DateFormat("yyyy/mm/dd"), :D, 10956), + ("1999/12/31", DateFormat("yyyy/mm/dd"), :s, 946598400), + # datetime with custom format + ("2000-02-29 12:34:56", DateFormat("yyyy-mm-dd HH:MM:SS"), :s, 951_827_696), + ( + "2000-02-29 12:34:56", + DateFormat("yyyy-mm-dd HH:MM:SS"), + :ns, + 951_827_696_000_000_000, + ), + ] + + @testset "$s $f $usym" for (s, f, usym, expected) in cases + # DateTime64 from string + format + dt64 = NumpyDates.DateTime64(s, f, usym) + @test Dates.value(dt64) == expected + + # Inline typed from string + format + Uconst = NumpyDates.Unit(usym) + inline_typed = NumpyDates.InlineDateTime64{Uconst}(s, f) + @test Dates.value(inline_typed) == expected + + # NOTE: InlineDateTime64(s, f, u) dynamic constructor is not exercised here + # because it currently dispatches to InlineTimeDelta64 in the implementation. + # When fixed, we can add: + # inline_dyn = NumpyDates.InlineDateTime64(s, f, usym) + # @test Dates.value(inline_dyn) == expected + end +end + +@testitem "DateTime64 from AbstractDateTime64" begin + using Dates + using PythonCall: NumpyDates + + # Pass-through when units match + d = Date(1970, 1, 2) + x = NumpyDates.DateTime64(d, :D) # expected value 1 + y = NumpyDates.DateTime64(x, :D) + @test Dates.value(y) == 1 + @test NumpyDates.unitpair(y) == NumpyDates.unitpair(:D) + + # NaT changes unit and remains NaT + nat_d = NumpyDates.DateTime64("NaT", :D) + z = NumpyDates.DateTime64(nat_d, :s) + @test isnan(z) + @test NumpyDates.unitpair(z) == NumpyDates.unitpair(:s) +end + +@testitem "DateTime64 from Integer" begin + using Dates + using PythonCall: NumpyDates + + # DateTime64(value, unit) + x = NumpyDates.DateTime64(946_684_799, :s) # 1999-12-31T23:59:59 + @test Dates.value(x) == 946_684_799 + @test Dates.DateTime(x) == DateTime(1999, 12, 31, 23, 59, 59) + + y = NumpyDates.DateTime64(10_956, :D) # 1999-12-31 + @test Dates.value(y) == 10_956 + @test Dates.Date(y) == Date(1999, 12, 31) + + # InlineDateTime64{U}(value) + s1 = NumpyDates.InlineDateTime64{NumpyDates.SECONDS}(3_600) # +1 hour + @test Dates.value(s1) == 3_600 + @test Dates.DateTime(s1) == DateTime(1970, 1, 1, 1, 0, 0) + + # InlineDateTime64 with multiplier in the unit parameter + s2 = NumpyDates.InlineDateTime64{(NumpyDates.SECONDS, 2)}(1_800) # 1_800 * 2s = 3_600s + @test Dates.value(s2) == 1_800 + @test Dates.DateTime(s2) == DateTime(1970, 1, 1, 1, 0, 0) +end + +@testitem "DateTime64 from Dates.DateTime" begin + using Dates + using PythonCall: NumpyDates + + # Data generated by: uv run test/scripts/np_dates.py + # Format: (Date, unit_symbol, numpy_int_value) + cases = [ + (DateTime(1969, 12, 31, 23, 0, 0), :Y, -1), + (DateTime(1969, 12, 31, 23, 0, 0), :M, -1), + (DateTime(1969, 12, 31, 23, 0, 0), :D, -1), + (DateTime(1969, 12, 31, 23, 0, 0), :h, -1), + (DateTime(1969, 12, 31, 23, 0, 0), :m, -60), + (DateTime(1969, 12, 31, 23, 0, 0), :s, -3600), + (DateTime(1969, 12, 31, 23, 0, 0), :ms, -3_600_000), + (DateTime(1969, 12, 31, 23, 0, 0), :us, -3_600_000_000), + (DateTime(1969, 12, 31, 23, 0, 0), :ns, -3_600_000_000_000), + (DateTime(1969, 12, 31, 23, 59, 59), :Y, -1), + (DateTime(1969, 12, 31, 23, 59, 59), :M, -1), + (DateTime(1969, 12, 31, 23, 59, 59), :D, -1), + (DateTime(1969, 12, 31, 23, 59, 59), :h, -1), + (DateTime(1969, 12, 31, 23, 59, 59), :m, -1), + (DateTime(1969, 12, 31, 23, 59, 59), :s, -1), + (DateTime(1969, 12, 31, 23, 59, 59), :ms, -1_000), + (DateTime(1969, 12, 31, 23, 59, 59), :us, -1_000_000), + (DateTime(1969, 12, 31, 23, 59, 59), :ns, -1_000_000_000), + (DateTime(1970, 1, 1, 0, 0, 0), :Y, 0), + (DateTime(1970, 1, 1, 0, 0, 0), :M, 0), + (DateTime(1970, 1, 1, 0, 0, 0), :D, 0), + (DateTime(1970, 1, 1, 0, 0, 0), :h, 0), + (DateTime(1970, 1, 1, 0, 0, 0), :m, 0), + (DateTime(1970, 1, 1, 0, 0, 0), :s, 0), + (DateTime(1970, 1, 1, 0, 0, 0), :ms, 0), + (DateTime(1970, 1, 1, 0, 0, 0), :us, 0), + (DateTime(1970, 1, 1, 0, 0, 0), :ns, 0), + (DateTime(1970, 1, 1, 0, 0, 1), :Y, 0), + (DateTime(1970, 1, 1, 0, 0, 1), :M, 0), + (DateTime(1970, 1, 1, 0, 0, 1), :D, 0), + (DateTime(1970, 1, 1, 0, 0, 1), :h, 0), + (DateTime(1970, 1, 1, 0, 0, 1), :m, 0), + (DateTime(1970, 1, 1, 0, 0, 1), :s, 1), + (DateTime(1970, 1, 1, 0, 0, 1), :ms, 1_000), + (DateTime(1970, 1, 1, 0, 0, 1), :us, 1_000_000), + (DateTime(1970, 1, 1, 0, 0, 1), :ns, 1_000_000_000), + (DateTime(1970, 1, 1, 1, 0, 0), :Y, 0), + (DateTime(1970, 1, 1, 1, 0, 0), :M, 0), + (DateTime(1970, 1, 1, 1, 0, 0), :D, 0), + (DateTime(1970, 1, 1, 1, 0, 0), :h, 1), + (DateTime(1970, 1, 1, 1, 0, 0), :m, 60), + (DateTime(1970, 1, 1, 1, 0, 0), :s, 3_600), + (DateTime(1970, 1, 1, 1, 0, 0), :ms, 3_600_000), + (DateTime(1970, 1, 1, 1, 0, 0), :us, 3_600_000_000), + (DateTime(1970, 1, 1, 1, 0, 0), :ns, 3_600_000_000_000), + (DateTime(1999, 12, 31, 23, 59, 59), :Y, 29), + (DateTime(1999, 12, 31, 23, 59, 59), :M, 359), + (DateTime(1999, 12, 31, 23, 59, 59), :D, 10_956), + (DateTime(1999, 12, 31, 23, 59, 59), :h, 262_967), + (DateTime(1999, 12, 31, 23, 59, 59), :m, 15_778_079), + (DateTime(1999, 12, 31, 23, 59, 59), :s, 946_684_799), + (DateTime(1999, 12, 31, 23, 59, 59), :ms, 946_684_799_000), + (DateTime(1999, 12, 31, 23, 59, 59), :us, 946_684_799_000_000), + (DateTime(1999, 12, 31, 23, 59, 59), :ns, 946_684_799_000_000_000), + (DateTime(2000, 2, 29, 12, 34, 56), :Y, 30), + (DateTime(2000, 2, 29, 12, 34, 56), :M, 361), + (DateTime(2000, 2, 29, 12, 34, 56), :D, 11_016), + (DateTime(2000, 2, 29, 12, 34, 56), :h, 264_396), + (DateTime(2000, 2, 29, 12, 34, 56), :m, 15_863_794), + (DateTime(2000, 2, 29, 12, 34, 56), :s, 951_827_696), + (DateTime(2000, 2, 29, 12, 34, 56), :ms, 951_827_696_000), + (DateTime(2000, 2, 29, 12, 34, 56), :us, 951_827_696_000_000), + (DateTime(2000, 2, 29, 12, 34, 56), :ns, 951_827_696_000_000_000), + (DateTime(1900, 1, 1, 0, 0, 0), :Y, -70), + (DateTime(1900, 1, 1, 0, 0, 0), :M, -840), + (DateTime(1900, 1, 1, 0, 0, 0), :D, -25_567), + (DateTime(1900, 1, 1, 0, 0, 0), :h, -613_608), + (DateTime(1900, 1, 1, 0, 0, 0), :m, -36_816_480), + (DateTime(1900, 1, 1, 0, 0, 0), :s, -2_208_988_800), + (DateTime(1900, 1, 1, 0, 0, 0), :ms, -2_208_988_800_000), + (DateTime(1900, 1, 1, 0, 0, 0), :us, -2_208_988_800_000_000), + (DateTime(1900, 1, 1, 0, 0, 0), :ns, -2_208_988_800_000_000_000), + (DateTime(2100, 1, 1, 0, 0, 0), :Y, 130), + (DateTime(2100, 1, 1, 0, 0, 0), :M, 1_560), + (DateTime(2100, 1, 1, 0, 0, 0), :D, 47_482), + (DateTime(2100, 1, 1, 0, 0, 0), :h, 1_139_568), + (DateTime(2100, 1, 1, 0, 0, 0), :m, 68_374_080), + (DateTime(2100, 1, 1, 0, 0, 0), :s, 4_102_444_800), + (DateTime(2100, 1, 1, 0, 0, 0), :ms, 4_102_444_800_000), + (DateTime(2100, 1, 1, 0, 0, 0), :us, 4_102_444_800_000_000), + (DateTime(2100, 1, 1, 0, 0, 0), :ns, 4_102_444_800_000_000_000), + ] + + @testset "$dt $usym" for (dt, usym, expected) in cases + # 1) DateTime64 with UnitArg symbol + dt64 = NumpyDates.DateTime64(dt, usym) + @test Dates.value(dt64) == expected + + # 2) InlineDateTime64 with type parameter Unit constant + Uconst = NumpyDates.Unit(usym) + inline_typed = NumpyDates.InlineDateTime64{Uconst}(dt) + @test Dates.value(inline_typed) == expected + + # 3) InlineDateTime64 with runtime UnitArg symbol + inline_dyn = NumpyDates.InlineDateTime64(dt, usym) + @test Dates.value(inline_dyn) == expected + @test NumpyDates.unitpair(inline_dyn) == NumpyDates.unitpair(usym) + end +end + +@testitem "DateTime64 show" begin + using Dates + using PythonCall: NumpyDates + + # Helper to get "showvalue" form by setting :typeinfo to the concrete type + function showvalue_string(x) + io = IOBuffer() + show(IOContext(io, :typeinfo => typeof(x)), x) + String(take!(io)) + end + + # Helper to get the default Base.show output (with type wrapper) + function show_string(x) + io = IOBuffer() + show(io, x) + String(take!(io)) + end + + # Cases: (kind, value, unit_symbol, expected_showvalue_string) + cases = [ + # 1) Days-aligned: prints Date in value form + (Date(1999, 12, 31), :D, "\"1999-12-31\"", "DAYS"), + + # 2) Seconds-aligned: prints DateTime in value form + (DateTime(1999, 12, 31, 23, 59, 59), :s, "\"1999-12-31T23:59:59\"", "SECONDS"), + + # 3) Sub-millisecond units: fall back to raw integer in value form + (1, :us, "1", "MICROSECONDS"), + (1, :ns, "1", "NANOSECONDS"), + + # 4) Calendar units (years/months): value form shows truncated Date + (Date(2000, 2, 29), :Y, "\"2000-01-01\"", "YEARS"), + (Date(1999, 12, 31), :M, "\"1999-12-01\"", "MONTHS"), + + # 5) NaT + ("NaT", :D, "\"NaT\"", "DAYS"), + ] + + @testset "$v $usym" for (v, usym, expected_val, ustr) in cases + # Construct DateTime64 + x = NumpyDates.DateTime64(v, usym) + + # showvalue checks + s_val = showvalue_string(x) + @test s_val == expected_val + + # default show checks + s_def = show_string(x) + @test s_def == + "PythonCall.NumpyDates.DateTime64($expected_val, PythonCall.NumpyDates.$ustr)" + + # and again with InlineDateTime64 + x2 = NumpyDates.InlineDateTime64(v, usym) + s_val2 = showvalue_string(x2) + @test s_val2 == expected_val + s_def2 = show_string(x2) + @test s_def2 == + "PythonCall.NumpyDates.InlineDateTime64{PythonCall.NumpyDates.$ustr}($expected_val)" + end +end + +@testitem "DateTime64 to Date/DateTime" begin + using Dates + using PythonCall: NumpyDates + + # Cases: (value, unit_symbol_or_tuple, expected_DateTime, expected_Date) + cases = [ + # Day counts (since 1970-01-01) + (10_956, :D, DateTime(1999, 12, 31, 0, 0, 0), Date(1999, 12, 31)), + (0, :D, DateTime(1970, 1, 1, 0, 0, 0), Date(1970, 1, 1)), + (1, :D, DateTime(1970, 1, 2, 0, 0, 0), Date(1970, 1, 2)), + + # Seconds since epoch + (946_684_799, :s, DateTime(1999, 12, 31, 23, 59, 59), Date(1999, 12, 31)), + (0, :s, DateTime(1970, 1, 1, 0, 0, 0), Date(1970, 1, 1)), + (3_600, :s, DateTime(1970, 1, 1, 1, 0, 0), Date(1970, 1, 1)), + + # Hours/minutes (epoch-based) + (24, :h, DateTime(1970, 1, 2, 0, 0, 0), Date(1970, 1, 2)), + (60, :m, DateTime(1970, 1, 1, 1, 0, 0), Date(1970, 1, 1)), + + # Years/months (calendar truncation semantics from 1970-01-01) + (30, :Y, DateTime(2000, 1, 1, 0, 0, 0), Date(2000, 1, 1)), + (361, :M, DateTime(2000, 2, 1, 0, 0, 0), Date(2000, 2, 1)), + (359, :M, DateTime(1999, 12, 1, 0, 0, 0), Date(1999, 12, 1)), + + # Weeks + (1, :W, DateTime(1970, 1, 8, 0, 0, 0), Date(1970, 1, 8)), + (-1, :W, DateTime(1969, 12, 25, 0, 0, 0), Date(1969, 12, 25)), + + # Sub-nanosecond units: floored to nanoseconds + (1_500, :ps, DateTime(1970, 1, 1, 0, 0, 0) + Nanosecond(1), Date(1970, 1, 1)), + (1_500_000, :fs, DateTime(1970, 1, 1, 0, 0, 0) + Nanosecond(1), Date(1970, 1, 1)), + ( + 1_500_000_000, + :as, + DateTime(1970, 1, 1, 0, 0, 0) + Nanosecond(1), + Date(1970, 1, 1), + ), + + # Multiplier tuple unit: value * multiplier is applied before adding + (1_800, (:s, 2), DateTime(1970, 1, 1, 1, 0, 0), Date(1970, 1, 1)), + ] + + @testset "$v $u" for (v, u, expdt, expd) in cases + # 1) DateTime64(value, unit) + x = NumpyDates.DateTime64(v, u) + @test Dates.DateTime(x) == expdt + @test Dates.Date(x) == expd + + # 2) InlineDateTime64 typed + Uconst = u isa Tuple ? (NumpyDates.Unit(u[1]), Int(u[2])) : NumpyDates.Unit(u) + y = NumpyDates.InlineDateTime64{Uconst}(v) + @test Dates.DateTime(y) == expdt + @test Dates.Date(y) == expd + + # 3) InlineDateTime64 dynamic + z = NumpyDates.InlineDateTime64(v, u) + @test Dates.DateTime(z) == expdt + @test Dates.Date(z) == expd + end + + # NaT conversion errors + for u in (:Y, :M, :W, :D, :h, :m, :s, :ms, :us, :ns, :ps, :fs, :as) + nat1 = NumpyDates.DateTime64("NaT", u) + @test_throws Exception Dates.DateTime(nat1) + @test_throws Exception Dates.Date(nat1) + + Uconst = NumpyDates.Unit(u) + nat2 = NumpyDates.InlineDateTime64{Uconst}("NaT") + @test_throws Exception Dates.DateTime(nat2) + @test_throws Exception Dates.Date(nat2) + + nat3 = NumpyDates.InlineDateTime64("NaT", u) + @test_throws Exception Dates.DateTime(nat3) + @test_throws Exception Dates.Date(nat3) + end +end + +@testitem "DateTime64 isnan" begin + using Dates + using PythonCall: NumpyDates + + units = (:Y, :M, :W, :D, :h, :m, :s, :ms, :us, :ns, :ps, :fs, :as) + + # Non-NaT examples should be false + @test !isnan(NumpyDates.DateTime64(Date(1970, 1, 1), :D)) + @test !isnan(NumpyDates.DateTime64(DateTime(1970, 1, 1), :ms)) + @test !isnan(NumpyDates.InlineDateTime64{NumpyDates.SECONDS}(0)) + @test !isnan(NumpyDates.InlineDateTime64(0, :ns)) + + # NaT via strings for each unit + @testset "NaT string -> $u" for u in units + x = NumpyDates.DateTime64("NaT", u) + @test isnan(x) + xi = NumpyDates.InlineDateTime64{NumpyDates.Unit(u)}("NaT") + @test isnan(xi) + xid = NumpyDates.InlineDateTime64("NaT", u) + @test isnan(xid) + end + + # NaT via sentinel (typemin(Int64)) + @testset "NAT sentinel -> $u" for u in units + x = NumpyDates.DateTime64(NumpyDates.NAT, u) + @test isnan(x) + xi = NumpyDates.InlineDateTime64{NumpyDates.Unit(u)}(NumpyDates.NAT) + @test isnan(xi) + end + + # Changing unit on NaT remains NaT + nat_d = NumpyDates.DateTime64("NaT", :D) + y = NumpyDates.DateTime64(nat_d, :s) + @test isnan(y) +end + +@testitem "TimeDelta64 from Dates.Period" begin + using Dates + using PythonCall: NumpyDates + + # Data generated by: uv run test/scripts/np_timedeltas.py + # Representative cases drawn from the script output. + # Each case is: (period, unit_symbol, expected_numpy_integer) + cases = [ + # negative one day + (Day(-1), :D, -1), + (Day(-1), :h, -24), + (Day(-1), :m, -1440), + (Day(-1), :s, -86400), + (Day(-1), :ms, -86400000), + (Day(-1), :us, -86400000000), + (Day(-1), :ns, -86400000000000), + # negative one week + (Week(-1), :W, -1), + (Week(-1), :D, -7), + (Week(-1), :h, -168), + (Week(-1), :m, -10080), + (Week(-1), :s, -604800), + (Week(-1), :ms, -604800000), + (Week(-1), :us, -604800000000), + (Week(-1), :ns, -604800000000000), + # sub-second basis + (Millisecond(-1000), :s, -1), + (Microsecond(-1_000_000), :s, -1), + (Nanosecond(-1_000_000_000), :s, -1), + (Second(-3600), :h, -1), + (Minute(-60), :h, -1), + # positive unit-expansion + (Second(1), :ms, 1_000), + (Second(1), :us, 1_000_000), + (Second(1), :ns, 1_000_000_000), + (Second(1), :ps, 1_000_000_000_000), + (Second(1), :fs, 1_000_000_000_000_000), + (Second(1), :as, 1_000_000_000_000_000_000), + (Nanosecond(1), :ns, 1), + (Nanosecond(1), :ps, 1_000), + (Nanosecond(1), :fs, 1_000_000), + (Nanosecond(1), :as, 1_000_000_000), + # mixed scale-ups + (Millisecond(1_000), :s, 1), + (Microsecond(1_000_000), :s, 1), + (Nanosecond(1_000_000_000), :s, 1), + # calendar-like periods (handled specially) + (Month(12), :M, 12), + (Year(1), :Y, 1), + (Year(2), :M, 24), + ] + + @testset "$p => $usym" for (p, usym, expected) in cases + # TimeDelta64 from Dates.Period + td = NumpyDates.TimeDelta64(p, usym) + @test Dates.value(td) == expected + + # Inline typed + Uconst = NumpyDates.Unit(usym) + inline_typed = NumpyDates.InlineTimeDelta64{Uconst}(p) + @test Dates.value(inline_typed) == expected + + # Inline dynamic + inline_dyn = NumpyDates.InlineTimeDelta64(p, usym) + @test Dates.value(inline_dyn) == expected + @test NumpyDates.unitpair(inline_dyn) == NumpyDates.unitpair(usym) + end + + inexact_cases = [(Millisecond(3001), :s), (Day(-1), :W)] + + @testset "$p => $usym" for (p, usym) in inexact_cases + @test_throws InexactError NumpyDates.TimeDelta64(p, usym) + @test_throws InexactError NumpyDates.InlineTimeDelta64(p, usym) + Uconst = NumpyDates.Unit(usym) + @test_throws InexactError NumpyDates.InlineTimeDelta64{Uconst}(p) + end +end + +@testitem "TimeDelta64 from String" begin + using Dates + using PythonCall: NumpyDates + + # Only NaT-like strings are supported for TimeDelta64(string, unit) + @testset "$nat -> $u" for nat in ("NaT", "nan", "NAN"), + u in (:W, :D, :h, :m, :s, :ms, :us, :ns, :ps, :fs, :as, :M, :Y) + + x = NumpyDates.TimeDelta64(nat, u) + @test isnan(x) + @test NumpyDates.unitpair(x) == NumpyDates.unitpair(u) + + # Inline typed and dynamic also accept NaT strings + Uconst = NumpyDates.Unit(u) + xi = NumpyDates.InlineTimeDelta64{Uconst}(nat) + @test isnan(xi) + xid = NumpyDates.InlineTimeDelta64(nat, u) + @test isnan(xid) + @test NumpyDates.unitpair(xid) == NumpyDates.unitpair(u) + end +end + +@testitem "TimeDelta64 from AbstractTimeDelta64" begin + using Dates + using PythonCall: NumpyDates + + # Pass-through when units match + base = NumpyDates.TimeDelta64(Day(1), :D) + y = NumpyDates.TimeDelta64(base, :D) + @test Dates.value(y) == 1 + @test NumpyDates.unitpair(y) == NumpyDates.unitpair(:D) + + # NaT changes unit and remains NaT + nat_td = NumpyDates.TimeDelta64("NaT", :s) + z = NumpyDates.TimeDelta64(nat_td, :ns) + @test isnan(z) + @test NumpyDates.unitpair(z) == NumpyDates.unitpair(:ns) +end + +@testitem "TimeDelta64 from Integer" begin + using Dates + using PythonCall: NumpyDates + + # Raw constructor: value and unit + x = NumpyDates.TimeDelta64(3_600, :s) + @test Dates.value(x) == 3_600 + + # Convert to nanoseconds via Dates to sanity check: 3_600 s -> 3_600_000_000_000 ns + # Note: TimeDelta64 is a Period; we check only value here and rely on conversion tests above. + + # Inline typed raw value + s1 = NumpyDates.InlineTimeDelta64{NumpyDates.SECONDS}(3_600) + @test Dates.value(s1) == 3_600 + + # Inline with multiplier in unit parameter + s2 = NumpyDates.InlineTimeDelta64{(NumpyDates.SECONDS, 2)}(1_800) # 1_800 * 2s = 3_600s + @test Dates.value(s2) == 1_800 +end + +@testitem "TimeDelta64 show" begin + using Dates + using PythonCall: NumpyDates + + # Helper to get "showvalue" form by setting :typeinfo to the concrete type + function showvalue_string(x) + io = IOBuffer() + show(IOContext(io, :typeinfo => typeof(x)), x) + String(take!(io)) + end + + # Helper to get the default Base.show output (with type wrapper) + function show_string(x) + io = IOBuffer() + show(io, x) + String(take!(io)) + end + + # Cases: (value_or_nat, unit_symbol_or_tuple, expected_value_string, expected_unit_string_for_default_show, is_inline_tuple?) + # - expected_value_string matches showvalue_string output (integers unquoted; NaT quoted) + # - expected_unit_string_for_default_show is the rhs inside the default show, e.g. "SECONDS" or "(SECONDS, 2)" + cases = [ + # simple seconds + (0, :s, "0", "SECONDS", false), + (3_600, :s, "3600", "SECONDS", false), + (-86_400, :s, "-86400", "SECONDS", false), + + # micro/nano/pico/femto/atto seconds + (1, :us, "1", "MICROSECONDS", false), + (1, :ns, "1", "NANOSECONDS", false), + (1, :ps, "1", "PICOSECONDS", false), + (1, :fs, "1", "FEMTOSECONDS", false), + (1, :as, "1", "ATTOSECONDS", false), + + # minutes/hours/days/weeks + (60, :m, "60", "MINUTES", false), + (24, :h, "24", "HOURS", false), + (7, :D, "7", "DAYS", false), + (2, :W, "2", "WEEKS", false), + + # calendar units (identity semantics for "value" field) + (12, :M, "12", "MONTHS", false), + (1, :Y, "1", "YEARS", false), + + # NaT + ("NaT", :ns, "\"NaT\"", "NANOSECONDS", false), + + # multiplier tuple unit for TimeDelta64 and InlineTimeDelta64 + (1_800, (:s, 2), "1800", "(SECONDS, 2)", true), + ] + + @testset "$v $u" for (v, u, expected_val, ustr, is_tuple) in cases + # Construct TimeDelta64 + td = NumpyDates.TimeDelta64(v, u) + + # showvalue checks (integers unquoted; NaT quoted) + s_val = showvalue_string(td) + @test s_val == expected_val + + # default show checks + s_def = show_string(td) + @test s_def == + "PythonCall.NumpyDates.TimeDelta64($expected_val, $(replace(ustr, r"^(\(?)" => s"\1PythonCall.NumpyDates.")))" + + # InlineTimeDelta64 forms + # Dynamic (value, unit) + inline_dyn = NumpyDates.InlineTimeDelta64(v, u) + s_val2 = showvalue_string(inline_dyn) + @test s_val2 == expected_val + s_def2 = show_string(inline_dyn) + # Inline default show embeds the fully-qualified unit in the type parameter. + if is_tuple + @test s_def2 == + "PythonCall.NumpyDates.InlineTimeDelta64{(PythonCall.NumpyDates.SECONDS, 2)}($expected_val)" + else + @test s_def2 == + "PythonCall.NumpyDates.InlineTimeDelta64{PythonCall.NumpyDates.$ustr}($expected_val)" + end + + # Typed Inline + Uconst = u isa Tuple ? (NumpyDates.Unit(u[1]), u[2]) : NumpyDates.Unit(u) + inline_typed = NumpyDates.InlineTimeDelta64{Uconst}(v) + @test showvalue_string(inline_typed) == expected_val + s_def3 = show_string(inline_typed) + if is_tuple + @test s_def3 == + "PythonCall.NumpyDates.InlineTimeDelta64{(PythonCall.NumpyDates.SECONDS, 2)}($expected_val)" + else + @test s_def3 == + "PythonCall.NumpyDates.InlineTimeDelta64{PythonCall.NumpyDates.$ustr}($expected_val)" + end + end +end + +@testitem "TimeDelta64 isnan" begin + using Dates + using PythonCall: NumpyDates + + units = (:Y, :M, :W, :D, :h, :m, :s, :ms, :us, :ns, :ps, :fs, :as) + + # Non-NaT examples should be false + @test !isnan(NumpyDates.TimeDelta64(0, :s)) + @test !isnan(NumpyDates.TimeDelta64(Day(1), :D)) + @test !isnan(NumpyDates.InlineTimeDelta64{NumpyDates.MINUTES}(0)) + @test !isnan(NumpyDates.InlineTimeDelta64(0, :ns)) + + # NaT via strings for each unit + @testset "NaT string -> $u" for u in units + x = NumpyDates.TimeDelta64("NaT", u) + @test isnan(x) + xi = NumpyDates.InlineTimeDelta64{NumpyDates.Unit(u)}("NaT") + @test isnan(xi) + xid = NumpyDates.InlineTimeDelta64("NaT", u) + @test isnan(xid) + end + + # NaT via sentinel (typemin(Int64)) + @testset "NAT sentinel -> $u" for u in units + x = NumpyDates.TimeDelta64(NumpyDates.NAT, u) + @test isnan(x) + xi = NumpyDates.InlineTimeDelta64{NumpyDates.Unit(u)}(NumpyDates.NAT) + @test isnan(xi) + end + + # Changing unit on NaT remains NaT + nat_td = NumpyDates.TimeDelta64("NaT", :s) + z = NumpyDates.TimeDelta64(nat_td, :ns) + @test isnan(z) +end + +@testitem "TimeDelta64 to Dates.Period" begin + using Dates + using PythonCall: NumpyDates + + # Mapping from unit symbols to Dates.Period constructors + const PERIOD_CONST = Dict( + :Y => Dates.Year, + :M => Dates.Month, + :D => Dates.Day, + :h => Dates.Hour, + :m => Dates.Minute, + :s => Dates.Second, + :ms => Dates.Millisecond, + :us => Dates.Microsecond, + :ns => Dates.Nanosecond, + ) + + # Test cases: (value, unit_symbol) + cases = [ + (1, :Y), + (-1, :Y), + (1, :M), + (-12, :M), + (1, :D), + (-7, :D), + (1, :h), + (-24, :h), + (1, :m), + (-60, :m), + (1, :s), + (-3600, :s), + (1, :ms), + (-1000, :ms), + (1, :us), + (-1000000, :us), + (1, :ns), + (-1000000000, :ns), + ] + + @testset "$v $usym" for (v, usym) in cases + # TimeDelta64 + td = NumpyDates.TimeDelta64(v, usym) + PeriodType = PERIOD_CONST[usym] + @test PeriodType(td) == PeriodType(v) + + # InlineTimeDelta64 typed + Uconst = NumpyDates.Unit(usym) + inline_typed = NumpyDates.InlineTimeDelta64{Uconst}(v) + @test PeriodType(inline_typed) == PeriodType(v) + + # InlineTimeDelta64 dynamic + inline_dyn = NumpyDates.InlineTimeDelta64(v, usym) + @test PeriodType(inline_dyn) == PeriodType(v) + end + + # NaT conversion errors + for usym in keys(PERIOD_CONST) + nat1 = NumpyDates.TimeDelta64("NaT", usym) + PeriodType = PERIOD_CONST[usym] + @test_throws Exception PeriodType(nat1) + + Uconst = NumpyDates.Unit(usym) + nat2 = NumpyDates.InlineTimeDelta64{Uconst}("NaT") + @test_throws Exception PeriodType(nat2) + + nat3 = NumpyDates.InlineTimeDelta64("NaT", usym) + @test_throws Exception PeriodType(nat3) + end +end + +@testitem "defaultunit" begin + using Dates + using PythonCall: NumpyDates + + # Helper: normalize any defaultunit result to a unitpair tuple + norm(u) = NumpyDates.unitpair(u) + + # 1) Primitives and Dates.* types + cases = [ + # fallback Any (e.g. AbstractString) + ("hello", :ns), + + # Dates.Date / Dates.DateTime + (Date(2000, 1, 2), :D), + (DateTime(2000, 1, 2, 3, 4, 5), :ms), + + # Dates.Periods => exact unit mapping + (Year(1), :Y), + (Month(1), :M), + (Week(1), :W), + (Day(1), :D), + (Hour(1), :h), + (Minute(1), :m), + (Second(1), :s), + (Millisecond(1), :ms), + (Microsecond(1), :us), + (Nanosecond(1), :ns), + ] + + @testset "$x -> $u" for (x, u) in cases + @test norm(NumpyDates.defaultunit(x)) == norm(u) + end + + # 2) (Inline)DateTime64 should return their own unitpair + @testset "AbstractDateTime64 instances" begin + # DateTime64 + dt_s = NumpyDates.DateTime64(0, :s) + dt_ms = NumpyDates.DateTime64(0, :ms) + @test norm(NumpyDates.defaultunit(dt_s)) == NumpyDates.unitpair(dt_s) + @test norm(NumpyDates.defaultunit(dt_ms)) == NumpyDates.unitpair(dt_ms) + + # InlineDateTime64 typed + idt_typed = NumpyDates.InlineDateTime64{NumpyDates.SECONDS}(0) + @test norm(NumpyDates.defaultunit(idt_typed)) == NumpyDates.unitpair(idt_typed) + + # InlineDateTime64 dynamic + idt_dyn = NumpyDates.InlineDateTime64(0, :us) + @test norm(NumpyDates.defaultunit(idt_dyn)) == NumpyDates.unitpair(idt_dyn) + end + + # 3) (Inline)TimeDelta64 should return their own unitpair + @testset "AbstractTimeDelta64 instances" begin + # TimeDelta64 + td_ns = NumpyDates.TimeDelta64(0, :ns) + td_ps = NumpyDates.TimeDelta64(0, :ps) + @test norm(NumpyDates.defaultunit(td_ns)) == NumpyDates.unitpair(td_ns) + @test norm(NumpyDates.defaultunit(td_ps)) == NumpyDates.unitpair(td_ps) + + # InlineTimeDelta64 typed + itd_typed = NumpyDates.InlineTimeDelta64{NumpyDates.MINUTES}(0) + @test norm(NumpyDates.defaultunit(itd_typed)) == NumpyDates.unitpair(itd_typed) + + # InlineTimeDelta64 dynamic + itd_dyn = NumpyDates.InlineTimeDelta64(0, :as) + @test norm(NumpyDates.defaultunit(itd_dyn)) == NumpyDates.unitpair(itd_dyn) + end +end diff --git a/test/scripts/np_dates.py b/test/scripts/np_dates.py new file mode 100644 index 00000000..4f4884d8 --- /dev/null +++ b/test/scripts/np_dates.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +import numpy as np + +dates = [ + "1969-12-31", + "1970-01-01", + "1970-01-02", + "1999-12-31", + "2000-02-29", + "1900-01-01", + "2100-01-01", +] + +# Note: skip 'W' to avoid week-anchor semantics; test uses floor(days/7) semantics. +units = ["Y", "M", "D", "h", "m", "s", "ms", "us", "ns"] + +# Output format: " " +for s in dates: + for u in units: + d = np.datetime64(s, u) + print(f"{s} {u} {int(d.astype('int64'))}") diff --git a/test/scripts/np_datetimes.py b/test/scripts/np_datetimes.py new file mode 100644 index 00000000..de08f1d5 --- /dev/null +++ b/test/scripts/np_datetimes.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +import numpy as np + +datetimes = [ + "1969-12-31T23:00:00", + "1969-12-31T23:59:59", + "1970-01-01T00:00:00", + "1970-01-01T00:00:01", + "1970-01-01T01:00:00", + "1999-12-31T23:59:59", + "2000-02-29T12:34:56", + "1900-01-01T00:00:00", + "2100-01-01T00:00:00", +] + +# Note: skip 'W' due to week-anchor semantics. +units = ["Y", "M", "D", "h", "m", "s", "ms", "us", "ns"] + +# Output format: " " +for s in datetimes: + for u in units: + d = np.datetime64(s, u) + print(f"{s} {u} {int(d.astype('int64'))}") diff --git a/test/scripts/np_timedeltas.py b/test/scripts/np_timedeltas.py new file mode 100644 index 00000000..ba1cb389 --- /dev/null +++ b/test/scripts/np_timedeltas.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +import numpy as np + +# Base periods to generate from, as (value, base_unit) +# Avoid calendar 'Y'/'M' as base for numpy timedelta (ambiguous); handle those separately in tests. +base_periods = [ + (-1_000_000_000, "ns"), + (-1_000_000, "us"), + (-1_000, "ms"), + (-3600, "s"), + (-60, "m"), + (-1, "h"), + (-1, "D"), + (-1, "W"), + (0, "ns"), + (1, "ns"), + (1, "us"), + (1, "ms"), + (1, "s"), + (60, "s"), + (3600, "s"), + (1, "m"), + (1, "h"), + (1, "D"), + (7, "D"), + (2, "W"), + (1_000, "ms"), + (1_000_000, "us"), + (1_000_000_000, "ns"), +] + +# Target units to cast to, including sub-ns +targets = ["W", "D", "h", "m", "s", "ms", "us", "ns", "ps", "fs", "as"] + +# Output format: +# " -> " +for v, b in base_periods: + tb = np.timedelta64(v, b) + for t in targets: + try: + out = int(tb.astype(f"timedelta64[{t}]").astype("int64")) + print(f"{v} {b} -> {t} {out}") + except Exception: + # Some casts may be invalid in older numpy; skip + pass + +# Calendar-like units for numpy timedelta: 'M' (months) and 'Y' (years) exist, but semantics differ. +# We only provide identity conversions here to validate unit-count semantics where appropriate. +for v in [-100, -12, -1, 0, 1, 12, 100]: + print(f"{v} M -> M {v}") +for v in [-100, -1, 0, 1, 100]: + print(f"{v} Y -> Y {v}")