diff --git a/docs/src/pythoncall-reference.md b/docs/src/pythoncall-reference.md index f09d5f12..2cdd75ba 100644 --- a/docs/src/pythoncall-reference.md +++ b/docs/src/pythoncall-reference.md @@ -181,6 +181,7 @@ Apart from a few fundamental immutable types, conversion from Python to Julia `A ```@docs PyList +PyTuple PySet PyDict PyIterable diff --git a/docs/src/releasenotes.md b/docs/src/releasenotes.md index c4b029cb..816f4847 100644 --- a/docs/src/releasenotes.md +++ b/docs/src/releasenotes.md @@ -11,6 +11,7 @@ * Adds methods `Py(::AbstractString)`, `Py(::AbstractChar)` (previously only builtin string and char types were allowed). * Adds methods `Py(::Integer)`, `Py(::Rational{<:Integer})`, `Py(::AbstractRange{<:Integer})` (previously only builtin integer types were allowed). * Adds method `pydict(::Pair...)` to construct a python `dict` from `Pair`s, similar to `Dict`. +* Added `PyTuple` wrapper for Python tuples with typed indexing. * Bug fixes. * Internal: switch from Requires.jl to package extensions. diff --git a/src/API/exports.jl b/src/API/exports.jl index 3d476182..ef44115f 100644 --- a/src/API/exports.jl +++ b/src/API/exports.jl @@ -117,6 +117,11 @@ export PyDict export PyIO export PyIterable export PyList +export PyTuple +export PyNTuple +for n = 0:8 + @eval export $(Symbol(:Py, n, :Tuple)) +end export PyPandasDataFrame export PySet export PyTable diff --git a/src/API/types.jl b/src/API/types.jl index 55a4211e..2ac7b7cc 100644 --- a/src/API/types.jl +++ b/src/API/types.jl @@ -181,6 +181,44 @@ struct PyList{T} <: AbstractVector{T} PyList{T}(x = pylist()) where {T} = new{T}(ispy(x) ? Py(x) : pylist(x)) end +""" + PyTuple{[T<:Tuple]}([x]) + +Wraps the Python tuple `x` as a Julia wrapper parametrised by a tuple type `T`. + +For example a `PyTuple{Tuple{Int,String}}` holds an `Int` and a `String`, a +`PyTuple{Tuple{Int,Vararg{String}}}` holds and `Int` and any number of `String`s, and +a `PyTuple{Tuple}` holds any number of anything. + +Supports `length(t)`, indexing `t[i]`, iteration `for x in t`, `Tuple(t)`, `eltype(t)` +just as for an ordinary `Tuple`. + +For convenience, these aliases are also exported: +- `PyNTuple{N,T}` for a tuple with `N` fields of the same type `T`, analogous to `NTuple{N,T}` +- `Py0Tuple`, `Py1Tuple{T1}`, ..., `Py8Tuple{T1,...,T8}` for tuples of a particular length +""" +struct PyTuple{T<:Tuple} + py::Py + Base.@propagate_inbounds function PyTuple{T}(x = pytuple()) where {T<:Tuple} + ans = new{T}(ispy(x) ? Py(x) : pytuple(x)) + @boundscheck ( + PythonCall.Wrap.check_length(ans) || error( + "tuple is incorrect length for this PyTuple type, got len=$(pylen(ans.py))", + ) + ) + ans + end +end + +const PyNTuple{N,T} = PyTuple{NTuple{N,T}} + +const Py0Tuple = PyTuple{Tuple{}} +for n = 1:8 + Ts = [Symbol(:T, i) for i = 1:n] + name = Symbol(:Py, n, :Tuple) + @eval $name{$(Ts...)} = PyTuple{Tuple{$(Ts...)}} +end + """ PyTable(x) diff --git a/src/Wrap/PyTuple.jl b/src/Wrap/PyTuple.jl new file mode 100644 index 00000000..bbe380c5 --- /dev/null +++ b/src/Wrap/PyTuple.jl @@ -0,0 +1,103 @@ +PyTuple(x = pytuple()) = PyTuple{Tuple}(x) + +ispy(::PyTuple) = true +Py(x::PyTuple) = x.py + +@generated function static_length(::PyTuple{T}) where {T} + try + fieldcount(T) + catch + nothing + end +end + +@generated function min_length(::PyTuple{T}) where {T} + count(!Base.isvarargtype, T.parameters) +end + +function check_length(x::PyTuple) + len = pylen(x.py) + explen = PythonCall.Wrap.static_length(x) + if explen === nothing + minlen = PythonCall.Wrap.min_length(x) + len ≥ minlen + else + len == explen + end +end + +Base.IteratorSize(::Type{<:PyTuple}) = Base.HasLength() + +Base.length(x::PyTuple{T}) where {T<:Tuple} = + @something(static_length(x), max(min_length(x), Int(pylen(x.py)))) + +Base.IteratorEltype(::Type{<:PyTuple}) = Base.HasEltype() + +Base.eltype(::Type{PyTuple{T}}) where {T<:Tuple} = eltype(T) + +Base.checkbounds(::Type{Bool}, x::PyTuple, i::Integer) = 1 ≤ i ≤ length(x) + +Base.checkbounds(x::PyTuple, i::Integer) = + if !checkbounds(Bool, x, i) + throw(BoundsError(x, i)) + end + +Base.@propagate_inbounds function Base.getindex(x::PyTuple{T}, i::Integer) where {T<:Tuple} + i = convert(Int, i)::Int + @boundscheck checkbounds(x, i) + E = fieldtype(T, i) + return pyconvert(E, @py x[@jl(i - 1)]) +end + +Base.@propagate_inbounds function Base.setindex!( + x::PyTuple{T}, + v, + i::Integer, +) where {T<:Tuple} + i = convert(Int, i)::Int + @boundscheck checkbounds(x, i) + E = fieldtype(T, i) + v = convert(E, v)::E + @py x[@jl(i - 1)] = v + x +end + +function Base.iterate(x::PyTuple{T}, ni = (length(x), 1)) where {T<:Tuple} + n, i = ni + if i > @something(static_length(x), n) + nothing + else + (x[i], (n, i + 1)) + end +end + +function Base.Tuple(x::PyTuple{T}) where {T<:Tuple} + n = static_length(x) + if n === nothing + ntuple(i -> x[i], length(x))::T + else + ntuple(i -> x[i], Val(n))::T + end +end + +# Conversion rule for Sequence -> PyTuple +function pyconvert_rule_sequence( + ::Type{T}, + x::Py, + ::Type{T1} = Utils._type_ub(T), +) where {T<:PyTuple,T1} + ans = @inbounds T1(x) + if check_length(ans) + pyconvert_return(ans) + else + pyconvert_unconverted() + end +end + +function Base.show(io::IO, mime::MIME"text/plain", x::PyTuple) + if !(get(io, :typeinfo, Any) <: PyTuple) + print(io, "PyTuple: ") + end + show(io, mime, Tuple(x)) + nothing +end diff --git a/src/Wrap/Wrap.jl b/src/Wrap/Wrap.jl index e67129cb..805ade7e 100644 --- a/src/Wrap/Wrap.jl +++ b/src/Wrap/Wrap.jl @@ -14,7 +14,7 @@ using ..Convert using ..PyMacro import ..PythonCall: - PyArray, PyDict, PyIO, PyIterable, PyList, PyPandasDataFrame, PySet, PyTable + PyArray, PyDict, PyIO, PyIterable, PyList, PyTuple, PyPandasDataFrame, PySet, PyTable using Base: @propagate_inbounds using Tables: Tables @@ -25,6 +25,7 @@ import ..Core: Py, ispy include("PyIterable.jl") include("PyDict.jl") include("PyList.jl") +include("PyTuple.jl") include("PySet.jl") include("PyArray.jl") include("PyIO.jl") @@ -69,6 +70,12 @@ function __init__() ) priority = PYCONVERT_PRIORITY_NORMAL + pyconvert_add_rule( + "collections.abc:Sequence", + PyTuple, + pyconvert_rule_sequence, + priority, + ) pyconvert_add_rule("", Array, pyconvert_rule_array, priority) pyconvert_add_rule("", Array, pyconvert_rule_array, priority) pyconvert_add_rule("", Array, pyconvert_rule_array, priority) diff --git a/test/Convert.jl b/test/Convert.jl index 4f137459..483073d9 100644 --- a/test/Convert.jl +++ b/test/Convert.jl @@ -196,6 +196,15 @@ end @test x2 == [1, 2, 3] end +@testitem "sequence → PyTuple" begin + x1 = pyconvert(PyTuple, pylist([1, "foo", "B"])) + @test x1 isa PyTuple{Tuple} + @test isequal(Tuple(x1), (1, "foo", "B")) + x2 = pyconvert(PyTuple{Tuple{Int,Symbol,Char}}, pylist([1, "foo", "B"])) + @test x2 isa PyTuple{Tuple{Int,Symbol,Char}} + @test isequal(Tuple(x2), (1, :foo, 'B')) +end + @testitem "set → PySet" begin x1 = pyconvert(PySet, pyset([1, 2, 3])) @test x1 isa PySet{Any} diff --git a/test/Wrap.jl b/test/Wrap.jl index c0af7ba2..77c0d3ff 100644 --- a/test/Wrap.jl +++ b/test/Wrap.jl @@ -570,3 +570,35 @@ end @test PyTable isa Type @test_throws Exception PyTable(0) end + +@testitem "PyTuple" begin + x = pytuple((1, "a")) + y = PyTuple(x) + z = PyTuple{Tuple{Int,String}}(x) + @testset "construct" begin + @test y isa PyTuple{Tuple} + @test z isa PyTuple{Tuple{Int,String}} + @test PythonCall.ispy(y) + @test PythonCall.ispy(z) + @test Py(y) === x + @test Py(z) === x + end + @testset "length" begin + @test length(y) == 2 + @test length(z) == 2 + v = PyTuple{Tuple{Int,Vararg{String}}}(pytuple((1, "a", "b"))) + @test length(v) == 3 + end + @testset "getindex" begin + @test_throws BoundsError y[0] + @test y[1] === 1 + @test y[2] == "a" + @test z[1] === 1 + @test z[2] == "a" + @test_throws BoundsError y[3] + end + @testset "Tuple" begin + @test Tuple(y) == (1, "a") + @test Tuple(z) == (1, "a") + end +end