diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d3bee8..56609490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased +* Added `juliacall.TypeValue.__numpy_dtype__` attribute to allow converting Julia types + to the corresponding NumPy dtype, like `numpy.dtype(jl.Int)`. + ## 0.9.31 (2025-12-17) * Restore support for Python 3.14+. diff --git a/docs/src/juliacall-reference.md b/docs/src/juliacall-reference.md index 0aa8e6d7..ef5e5e5c 100644 --- a/docs/src/juliacall-reference.md +++ b/docs/src/juliacall-reference.md @@ -200,6 +200,11 @@ from juliacall import Main as jl # equivalent to Vector{Int}() in Julia jl.Vector[jl.Int]() ``` + +Some Julia types can be converted to corresponding numpy dtypes like `numpy.dtype(jl.Int)`. +Supports primitive types: `Bool`, `IntXX`, `UIntXX`, `FloatXX`, `ComplexFXX`, +`NumpyDates.InlineDateTime64{unit}` and `NumpyDates.InlineTimeDelta64{unit}`. Also +supports tuples, named tuples and structs of these. ````` `````@customdoc diff --git a/src/JlWrap/type.jl b/src/JlWrap/type.jl index 72a2f992..00bbf058 100644 --- a/src/JlWrap/type.jl +++ b/src/JlWrap/type.jl @@ -11,6 +11,22 @@ function pyjltype_getitem(self::Type, k_) end end +function pyjltype_numpy_dtype(self::Type) + typestr, descr = pytypestrdescr(self) + if isempty(typestr) + errset(pybuiltins.AttributeError, "__numpy_dtype__") + return PyNULL + end + np = pyimport("numpy") + if pyisnull(descr) + return np.dtype(typestr) + else + return np.dtype(descr) + end +end + +pyjl_handle_error_type(::typeof(pyjltype_numpy_dtype), x, exc) = pybuiltins.AttributeError + function init_type() jl = pyjuliacallmodule pybuiltins.exec( @@ -25,6 +41,9 @@ class TypeValue(AnyValue): raise TypeError("not supported") def __delitem__(self, k): raise TypeError("not supported") + @property + def __numpy_dtype__(self): + return self._jl_callmethod($(pyjl_methodnum(pyjltype_numpy_dtype))) """, @__FILE__(), "exec", diff --git a/test/JlWrap.jl b/test/JlWrap.jl index 52d93642..818f83de 100644 --- a/test/JlWrap.jl +++ b/test/JlWrap.jl @@ -472,13 +472,72 @@ end end end -@testitem "type" begin +@testitem "type" setup = [Setup] begin + using PythonCall.NumpyDates @testset "type" begin @test pyis(pytype(pyjl(Int)), PythonCall.pyjltypetype) end @testset "bool" begin @test pytruth(pyjl(Int)) end + @testset "numpy dtype" begin + if Setup.devdeps + np = pyimport("numpy") + + # success cases + @testset "$t -> $d" for (t, d) in [ + (Bool, "bool"), + (Int8, "int8"), + (Int16, "int16"), + (Int32, "int32"), + (Int64, "int64"), + (UInt8, "uint8"), + (UInt16, "uint16"), + (UInt32, "uint32"), + (UInt64, "uint64"), + (Float16, "float16"), + (Float32, "float32"), + (Float64, "float64"), + (ComplexF32, "complex64"), + (ComplexF64, "complex128"), + (InlineDateTime64{SECONDS}, "datetime64[s]"), + (InlineDateTime64{(SECONDS, 5)}, "datetime64[5s]"), + (InlineDateTime64{NumpyDates.UNBOUND_UNITS}, "datetime64"), + (InlineTimeDelta64{MINUTES}, "timedelta64[m]"), + (InlineTimeDelta64{(SECONDS, 5)}, "timedelta64[5s]"), + (InlineTimeDelta64{NumpyDates.UNBOUND_UNITS}, "timedelta64"), + (Tuple{}, pylist()), + (Tuple{Int32, Int32}, pylist([("f0", "int32"), ("f1", "int32")])), + (@NamedTuple{}, pylist()), + (@NamedTuple{x::Int32, y::Int32}, pylist([("x", "int32"), ("y", "int32")])), + (Pair{Int32, Int32}, pylist([("first", "int32"), ("second", "int32")])), + ] + @test pyeq(Bool, pygetattr(pyjl(t), "__numpy_dtype__"), np.dtype(d)) + @test pyeq(Bool, np.dtype(pyjl(t)), np.dtype(d)) + end + + # unsupported cases + @testset "$t -> AttributeError" for t in [ + # non-primitives or mutables + String, + Vector{Int}, + # pointers + Ptr{Cvoid}, + Ptr{Int}, + # PyPtr specifically should NOT be interpreted as np.dtype("O") + PythonCall.C.PyPtr, + ] + err = try + pygetattr(pyjl(t), "__numpy_dtype__") + nothing + catch err + err + end + @test err isa PythonCall.PyException + @test pyis(err.t, pybuiltins.AttributeError) + end + end + end end @testitem "vector" begin